From 2aa0384890aabf27e54d80fb16eebb29d86a81a7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 13:45:15 +0200 Subject: [PATCH 001/218] add .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df7d134 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.luac \ No newline at end of file From 299d1e302a90327cd34f61a6bcabaacc1fd3cb5a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 13:45:50 +0200 Subject: [PATCH 002/218] Move blackwhite lua script to blackwhite dir --- elrs.lua => blackwhite/elrs.lua | 0 {mockup => blackwhite/mockup}/README.md | 0 {mockup => blackwhite/mockup}/elrsmock.lua | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename elrs.lua => blackwhite/elrs.lua (100%) rename {mockup => blackwhite/mockup}/README.md (100%) rename {mockup => blackwhite/mockup}/elrsmock.lua (100%) diff --git a/elrs.lua b/blackwhite/elrs.lua similarity index 100% rename from elrs.lua rename to blackwhite/elrs.lua diff --git a/mockup/README.md b/blackwhite/mockup/README.md similarity index 100% rename from mockup/README.md rename to blackwhite/mockup/README.md diff --git a/mockup/elrsmock.lua b/blackwhite/mockup/elrsmock.lua similarity index 100% rename from mockup/elrsmock.lua rename to blackwhite/mockup/elrsmock.lua From 324d39b105a82e6e3f725c33f0bdf107d1d7a814 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 13:47:49 +0200 Subject: [PATCH 003/218] Add lvgl lua, new lvgl based widgets --- .gitignore | 3 +- color/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 1038 ++++++++++ color/SCRIPTS/ELRSLib/crsf.lua | 340 ++++ color/SCRIPTS/TOOLS/expresslrs.lua | 1807 +++++++++++++++++ color/WIDGETS/ELRSTelemetry/loadable.lua | 556 +++++ color/WIDGETS/ELRSTelemetry/main.lua | 43 + color/WIDGETS/ELRSTelemetry/ui/hd.lua | 191 ++ color/WIDGETS/ELRSTelemetry/ui/portrait.lua | 180 ++ color/WIDGETS/ELRSTelemetry/ui/sd.lua | 181 ++ color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 192 ++ color/WIDGETS/ELRSTelemetry/ui/small.lua | 185 ++ color/WIDGETS/ELRSTelemetry/ui/topbar.lua | 66 + color/WIDGETS/ELRSVTXAdmin/loadable.lua | 1036 ++++++++++ color/WIDGETS/ELRSVTXAdmin/main.lua | 43 + color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 174 ++ color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 264 +++ color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 173 ++ color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 201 ++ color/WIDGETS/ELRSVTXAdmin/ui/small.lua | 226 +++ color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 40 + 20 files changed, 6938 insertions(+), 1 deletion(-) create mode 100644 color/SCRIPTS/CRSFSimulator/csrfsimulator.lua create mode 100644 color/SCRIPTS/ELRSLib/crsf.lua create mode 100644 color/SCRIPTS/TOOLS/expresslrs.lua create mode 100644 color/WIDGETS/ELRSTelemetry/loadable.lua create mode 100644 color/WIDGETS/ELRSTelemetry/main.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/hd.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/portrait.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/sd.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/small.lua create mode 100644 color/WIDGETS/ELRSTelemetry/ui/topbar.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/loadable.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/main.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/hd.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/sd.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/small.lua create mode 100644 color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua diff --git a/.gitignore b/.gitignore index df7d134..95520e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -*.luac \ No newline at end of file +*.luac +color/WIDGETS/ELRSVTXAdmin/presets.txt diff --git a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua new file mode 100644 index 0000000..3cb2661 --- /dev/null +++ b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -0,0 +1,1038 @@ +-- ============================================================================ +-- CRSF Simulator: Packet-level mock for crossfireTelemetryPop/Push +-- ============================================================================ +-- This module simulates the CRSF protocol at the packet level, allowing the +-- ELRS Lua script to exercise the full communication flow (device discovery, +-- parameter loading, value writes, ELRS status) in the EdgeTX simulator. +-- +-- Usage: loaded by elrs_lvgl3.lua setMock() when running in simulator mode. +-- Returns a table with { pop, push, moduleFound } fields. +-- ============================================================================ + +-- ============================================================================ +-- Configuration: Change scenario here to test different states +-- ============================================================================ + +-- Scenarios: +-- "normal" TX + RX connected. Happy path with full telemetry, link +-- stats, and all parameters from both devices. +-- "disconnected" TX present but no RX. Shows "No link" in subtitle. +-- No receiver device in Other Devices list. +-- "reconnect" Starts disconnected, then transitions to connected after +-- ~5 seconds. Tests auto-discovery of Other Devices on +-- reconnect without restarting the script. +-- "model_mismatch" TX + RX connected but with Model ID mismatch flag set. +-- Triggers the Model Mismatch warning dialog. +-- "armed" TX + RX connected with the "is Armed" warning flag set. +-- Shows armed warning in subtitle. +-- "no_module" No CRSF module found at all. Triggers the "No Module +-- Found" error dialog immediately. +local config = { + scenario = "normal", +} + +-- ============================================================================ +-- CRSF Protocol Constants (local copies, independent of Protocol.CRSF) +-- ============================================================================ + +local CRSF = { + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- Field types0 + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- Command steps + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, +} + +-- ============================================================================ +-- Rate configuration table (matches SX128X 2.4GHz from common.cpp) +-- Maps Packet Rate option index to Hz, interval (µs), and default TLM ratio +-- TLM ratio indices into "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race": +-- 0=Std, 1=Off, 2=1:128, 3=1:64, 4=1:32, 5=1:16, 6=1:8, 7=1:4, 8=1:2, 9=Race +-- ============================================================================ + +local rateConfigs = { + [0] = { hz = 50, interval = 20000, defaultTlm = 5 }, -- TLM_RATIO_1_16 + [1] = { hz = 150, interval = 6666, defaultTlm = 4 }, -- TLM_RATIO_1_32 + [2] = { hz = 250, interval = 4000, defaultTlm = 3 }, -- TLM_RATIO_1_64 + [3] = { hz = 500, interval = 2000, defaultTlm = 2 }, -- TLM_RATIO_1_128 +} + +-- ============================================================================ +-- FIFO Packet Queue +-- ============================================================================ + +local packetQueue = {} +local queueHead = 1 + +-- Deferred packets simulate OTA relay delay (e.g., RX DEVICE_INFO arriving +-- later than TX DEVICE_INFO). They are delivered in the NEXT poll cycle, +-- after the main queue has been drained and a nil has been returned. +local deferredQueue = {} +local deferredReady = false + +-- Deferred folder name updates simulate the firmware event loop gap: +-- PARAMETER_WRITE callbacks set config values immediately, but +-- updateFolderNames() runs on the NEXT event loop iteration. +-- A PARAMETER_READ arriving before that gets stale dynName. +-- Delay is time-based (getTime() ticks, 10ms each) to be independent of +-- how often mockPop is called within a single Protocol.poll() cycle. +local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20ms delay +local folderNamesReadyAt = 0 +local folderNamesDevice = nil + +local function queuePush(command, data) + packetQueue[#packetQueue + 1] = { command = command, data = data } +end + +local function queuePushDeferred(command, data) + deferredQueue[#deferredQueue + 1] = { command = command, data = data } +end + +local function queuePop() + -- Serve from main queue first + if queueHead <= #packetQueue then + local pkt = packetQueue[queueHead] + queueHead = queueHead + 1 + deferredReady = false + return pkt.command, pkt.data + end + + -- Main queue empty, reset it + packetQueue = {} + queueHead = 1 + + -- Serve deferred packets only after a nil has been returned (next poll cycle) + if deferredReady and #deferredQueue > 0 then + local pkt = table.remove(deferredQueue, 1) + return pkt.command, pkt.data + end + + -- Mark deferred as ready for the next poll cycle + if #deferredQueue > 0 then + deferredReady = true + end + + return nil +end + +-- ============================================================================ +-- String-to-bytes helper +-- ============================================================================ + +local function appendString(tbl, str) + for i = 1, #str do + tbl[#tbl + 1] = string.byte(str, i) + end + tbl[#tbl + 1] = 0 -- null terminator +end + +local function appendU32BE(tbl, val) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 24), 0xFF) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 16), 0xFF) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 8), 0xFF) + tbl[#tbl + 1] = bit32.band(val, 0xFF) +end + +local function appendU16BE(tbl, val) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 8), 0xFF) + tbl[#tbl + 1] = bit32.band(val, 0xFF) +end + +-- ============================================================================ +-- CRSF Packet Encoders +-- ============================================================================ + +--- Encode a DEVICE_INFO response packet (frame type 0x29) +-- @param device table with: id, name, serialNo, hwVer, swVer, fieldCount +-- @param destAddr destination address (usually ADDRESS_RADIO_TRANSMITTER) +-- @return data table suitable for queuePush(FRAMETYPE_DEVICE_INFO, data) +local function encodeDeviceInfo(device, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = device.id + -- Device name (null-terminated) + appendString(data, device.name) + -- Serial number (4 bytes BE) + appendU32BE(data, device.serialNo or CRSF.ELRS_SERIAL_ID) + -- Hardware version (4 bytes BE) + appendU32BE(data, device.hwVer or 0) + -- Software version (4 bytes BE) + appendU32BE(data, device.swVer or 0x00030500) -- 3.5.0 + -- Field count + data[#data + 1] = device.fieldCount + -- Parameter version + data[#data + 1] = 0 + return data +end + +--- Encode a PARAMETER_SETTINGS_ENTRY packet (frame type 0x2B) +-- Encodes one chunk of a parameter. Currently only single-chunk (chunk 0) supported. +-- @param device the device table (for id) +-- @param param the parameter definition table +-- @param chunk chunk index (0 for single-chunk params) +-- @param destAddr destination address +-- @return data table suitable for queuePush(FRAMETYPE_PARAMETER_SETTINGS_ENTRY, data) +local function encodeParameterEntry(device, param, chunk, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = device.id + data[3] = param.id -- Field ID + data[4] = 0 -- Chunks remaining (0 = single chunk) + data[5] = param.parent or 0 -- Parent ID (0 = root) + data[6] = param.type -- Type byte (with hidden flag if needed) + if param.hidden then + data[6] = bit32.bor(data[6], 0x80) + end + + -- Parameter name (null-terminated) — use dynamic name if set (e.g. folder summaries) + appendString(data, param.dynName or param.name) + + -- Type-specific value data + local t = bit32.band(param.type, 0x7F) + + if t == CRSF.TEXT_SELECTION then + -- Options string (semicolon-separated, null-terminated) + appendString(data, param.options) + -- Value (current selection index) + data[#data + 1] = param.value or 0 + -- Min + data[#data + 1] = 0 + -- Max (count of options - 1) + local optCount = 1 + for i = 1, #param.options do + if string.byte(param.options, i) == 59 then -- ';' + optCount = optCount + 1 + end + end + data[#data + 1] = optCount - 1 + -- Default + data[#data + 1] = 0 + -- Units (null-terminated) + appendString(data, param.units or "") + + elseif t == CRSF.COMMAND then + -- Status + data[#data + 1] = param.status or CRSF.CMD_IDLE + -- Timeout (in 10ms ticks, 200 = 2s) + data[#data + 1] = param.timeout or 200 + -- Info string (null-terminated) + appendString(data, param.info or "") + + elseif t == CRSF.FOLDER then + -- Folder contains a list of child parameter IDs terminated by 0xFF. + -- This allows the Lua script to know which fields to load for this folder. + -- We need the device context to scan for children. + if param._device then + for _, p in ipairs(param._device.params) do + if (param.id == 0 and (p.parent == 0 or p.parent == nil)) or + (param.id ~= 0 and p.parent == param.id) then + data[#data + 1] = p.id + end + end + end + data[#data + 1] = 0xFF -- terminator + + elseif t == CRSF.INFO then + -- Info value string (null-terminated) + appendString(data, param.value or "") + + elseif t == CRSF.STRING then + -- String value (null-terminated) + appendString(data, param.value or "") + + elseif t == CRSF.UINT8 then + -- value, min, max (1 byte each) + data[#data + 1] = param.value or 0 + data[#data + 1] = param.min or 0 + data[#data + 1] = param.max or 255 + -- default + data[#data + 1] = param.default or 0 + -- units + appendString(data, param.units or "") + + elseif t == CRSF.INT8 then + -- Same as UINT8 but values may be signed (stored as unsigned in wire format) + local v = param.value or 0 + if v < 0 then + v = v + 256 + end + local mn = param.min or 0 + if mn < 0 then + mn = mn + 256 + end + local mx = param.max or 127 + if mx < 0 then + mx = mx + 256 + end + data[#data + 1] = v + data[#data + 1] = mn + data[#data + 1] = mx + data[#data + 1] = param.default or 0 + appendString(data, param.units or "") + + elseif t == CRSF.UINT16 or t == CRSF.INT16 then + -- value, min, max (2 bytes BE each) + appendU16BE(data, param.value or 0) + appendU16BE(data, param.min or 0) + appendU16BE(data, param.max or 65535) + -- default (2 bytes) + appendU16BE(data, param.default or 0) + appendString(data, param.units or "") + + elseif t == CRSF.FLOAT then + -- value, min, max, default (4 bytes BE each), precision (1 byte), step (4 bytes BE) + appendU32BE(data, param.value or 0) + appendU32BE(data, param.min or 0) + appendU32BE(data, param.max or 0) + appendU32BE(data, param.default or 0) + data[#data + 1] = param.prec or 0 + appendU32BE(data, param.step or 1) + appendString(data, param.units or "") + end + + return data +end + +--- Encode an ELRS_STATUS packet (frame type 0x2E) +-- @param deviceId source device address +-- @param destAddr destination address +-- @param badPkts bad packets count (uint8) +-- @param goodPkts good packets count (uint16) +-- @param flags warning flags byte +-- @param flagsInfo warning message string +-- @return data table +local function encodeElrsStatus(deviceId, destAddr, badPkts, goodPkts, flags, flagsInfo) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = deviceId + data[3] = badPkts or 0 + -- Good packets as uint16 BE + appendU16BE(data, goodPkts or 0) + data[#data + 1] = flags or 0 + -- Warning info string (null-terminated) + appendString(data, flagsInfo or "") + return data +end + +-- ============================================================================ +-- TX Device Definition (address 0xEE) +-- Matches TXModuleParameters.cpp parameter structure +-- ============================================================================ + +local txDevice = { + id = CRSF.ADDRESS_CRSF_TRANSMITTER, + name = "TX16S MK3", + serialNo = CRSF.ELRS_SERIAL_ID, + hwVer = 0, + swVer = 0x00030500, -- 3.5.0 + fieldCount = 21, -- total parameter count + params = { + { id = 1, parent = 0, type = CRSF.TEXT_SELECTION, name = "Packet Rate", + options = "50(-117dBm);150(-112dBm);250(-108dBm);500(-105dBm)", value = 2, units = "Hz" }, + { id = 2, parent = 0, type = CRSF.TEXT_SELECTION, name = "Telem Ratio", + options = "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race", value = 0, units = " (1:64)" }, + { id = 3, parent = 0, type = CRSF.TEXT_SELECTION, name = "Switch Mode", + options = "Hybrid;Wide", value = 1, units = "" }, + { id = 4, parent = 0, type = CRSF.TEXT_SELECTION, name = "Model Match", + options = "Off;On", value = 0, units = "(ID: 1)" }, + { id = 5, parent = 0, type = CRSF.TEXT_SELECTION, name = "Antenna Mode", + options = "Gemini;Ant 1;Ant 2;Switch", value = 0, units = "" }, + + -- TX Power folder + { id = 6, parent = 0, type = CRSF.FOLDER, name = "TX Power" }, + { id = 7, parent = 6, type = CRSF.TEXT_SELECTION, name = "Max Power", + options = "10;25;50;100;250", value = 4, units = "mW" }, + { id = 8, parent = 6, type = CRSF.TEXT_SELECTION, name = "Dynamic", + options = "Off;Dyn;AUX9;AUX10;AUX11;AUX12", value = 1, units = "" }, + { id = 9, parent = 6, type = CRSF.TEXT_SELECTION, name = "Fan Thresh", + options = "10mW;25mW;50mW;100mW;250mW", value = 3, units = "" }, + + -- VTX Administrator folder + { id = 10, parent = 0, type = CRSF.FOLDER, name = "VTX Administrator" }, + { id = 11, parent = 10, type = CRSF.TEXT_SELECTION, name = "Band", + options = "Off;A;B;E;F;R;L", value = 5, units = "" }, + { id = 12, parent = 10, type = CRSF.UINT8, name = "Channel", + value = 1, min = 1, max = 8, units = "" }, + { id = 13, parent = 10, type = CRSF.TEXT_SELECTION, name = "Pwr Lvl", + options = "-;1;2;3;4;5;6;7;8", value = 0, units = "" }, + { id = 14, parent = 10, type = CRSF.TEXT_SELECTION, name = "Pitmode", + options = "Off;On", value = 0, units = "" }, + { id = 15, parent = 10, type = CRSF.COMMAND, name = "Send VTx", + status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + + -- WiFi Connectivity folder + { id = 16, parent = 0, type = CRSF.FOLDER, name = "WiFi Connectivity" }, + { id = 17, parent = 16, type = CRSF.COMMAND, name = "Enable WiFi", + status = CRSF.CMD_IDLE, timeout = 50, info = "", persistent = true }, -- runs until cancelled + { id = 18, parent = 16, type = CRSF.COMMAND, name = "Enable Rx WiFi", + status = CRSF.CMD_IDLE, timeout = 50, info = "", persistent = true }, -- runs until cancelled + + -- Root-level commands and info + { id = 19, parent = 0, type = CRSF.COMMAND, name = "Bind", + status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + + -- Bad/Good (hidden from ELRS Lua, visible to other UIs) + { id = 20, parent = 0, type = CRSF.INFO, name = "Bad/Good", + value = "0/250", hidden = true }, + + -- Version + regulatory domain (name = version+domain, value = commit hash) + { id = 21, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", + value = "825ed8" }, + }, +} + +-- ============================================================================ +-- RX Device Definition (address 0xEC) +-- Matches RXParameters.cpp parameter structure +-- ============================================================================ + +local rxDevice = { + id = CRSF.ADDRESS_CRSF_RECEIVER, + name = "ELRS 2400RX", + serialNo = CRSF.ELRS_SERIAL_ID, + hwVer = 0, + swVer = 0x00030500, -- 3.5.0 + fieldCount = 22, -- total parameter count + params = { + { id = 1, parent = 0, type = CRSF.TEXT_SELECTION, name = "Protocol", + options = "CRSF;Inverted CRSF;SBUS;Inverted SBUS;SUMD;DJI RS Pro;HoTT Telemetry;MAVLink;DisplayPort;GPS", + value = 0, units = "" }, + { id = 2, parent = 0, type = CRSF.TEXT_SELECTION, name = "SBUS failsafe", + options = "No Pulses;Last Pos", value = 0, units = "" }, + { id = 3, parent = 0, type = CRSF.TEXT_SELECTION, name = "Ant. Mode", + options = "Antenna A;Antenna B;Diversity", value = 2, units = "" }, + { id = 4, parent = 0, type = CRSF.TEXT_SELECTION, name = "Tlm Power", + options = "10;25;50;100;250;MatchTX", value = 2, units = "mW" }, + + -- Team Race folder + { id = 5, parent = 0, type = CRSF.FOLDER, name = "Team Race" }, + { id = 6, parent = 5, type = CRSF.TEXT_SELECTION, name = "Channel", + options = "AUX2;AUX3;AUX4;AUX5;AUX6;AUX7;AUX8;AUX9;AUX10;AUX11;AUX12", + value = 0, units = "" }, + { id = 7, parent = 5, type = CRSF.TEXT_SELECTION, name = "Position", + options = "Disabled;1/Low;2;3;Mid;4;5;6/High", value = 0, units = "" }, + + -- Output Mapping folder + { id = 8, parent = 0, type = CRSF.FOLDER, name = "Output Mapping" }, + { id = 9, parent = 8, type = CRSF.UINT8, name = "Output Ch", + value = 1, min = 1, max = 4, units = "" }, + { id = 10, parent = 8, type = CRSF.UINT8, name = "Input Ch", + value = 1, min = 1, max = 16, units = "" }, + { id = 11, parent = 8, type = CRSF.TEXT_SELECTION, name = "Output Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz;10kHzDuty;On/Off;DShot", + value = 0, units = "" }, + { id = 12, parent = 8, type = CRSF.TEXT_SELECTION, name = "Invert", + options = "Off;On", value = 0, units = "" }, + + -- PWM Channel 1 subfolder (nested inside Output Mapping) + { id = 13, parent = 8, type = CRSF.FOLDER, name = "PWM Ch1" }, + { id = 14, parent = 13, type = CRSF.UINT8, name = "Failsafe", + value = 0, min = 0, max = 100, units = "%" }, + { id = 15, parent = 13, type = CRSF.TEXT_SELECTION, name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", value = 0, units = "" }, + + -- PWM Channel 2 subfolder (nested inside Output Mapping) + { id = 16, parent = 8, type = CRSF.FOLDER, name = "PWM Ch2" }, + { id = 17, parent = 16, type = CRSF.UINT8, name = "Failsafe", + value = 0, min = 0, max = 100, units = "%" }, + { id = 18, parent = 16, type = CRSF.TEXT_SELECTION, name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", value = 0, units = "" }, + + -- Bind Storage & Bind Mode + { id = 19, parent = 0, type = CRSF.TEXT_SELECTION, name = "Bind Storage", + options = "Persistent;Volatile;Returnable;Administered", value = 0, units = "" }, + { id = 20, parent = 0, type = CRSF.COMMAND, name = "Enter Bind Mode", + status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + + -- Model Id + { id = 21, parent = 0, type = CRSF.INFO, name = "Model Id", + value = "12" }, + + -- Info fields + { id = 22, parent = 0, type = CRSF.INFO, name = "RX Version", + value = "3.5.0 825ed8" }, + }, +} + +-- ============================================================================ +-- Parameter lookup helper +-- ============================================================================ + +local function findParam(device, fieldId) + for _, p in ipairs(device.params) do + if p.id == fieldId then + return p + end + end + return nil +end + +local function findDeviceByAddr(addr) + if addr == txDevice.id then + return txDevice + end + if rxDevice and addr == rxDevice.id then + return rxDevice + end + return nil +end + +--- Extract the Nth label (0-indexed) from a semicolon-separated options string. +-- Matches the firmware's findSelectionLabel() behavior. +-- @param options semicolon-separated string (e.g. "10;25;50;100;250") +-- @param index 0-based index +-- @return label string, or "" if index is out of range +local function getOptionLabel(options, index) + local i = 0 + for label in string.gmatch(options, "([^;]+)") do + if i == index then + return label + end + i = i + 1 + end + return "" +end + +-- ============================================================================ +-- Dynamic folder names (mirrors TXModuleParameters.cpp updateFolderNames) +-- ============================================================================ + +--- Update the dynName field on TX Power and VTX Administrator folders +-- so the simulator matches real firmware behavior where folder names show +-- a summary of the current settings in parentheses. +-- @param device the device table whose params to update +local function updateFolderNames(device) + -- TX Power folder (id=6): children Max Power (id=7), Dynamic (id=8) + local txPwrFolder = findParam(device, 6) + local maxPower = findParam(device, 7) + local dynamic = findParam(device, 8) + if txPwrFolder and maxPower then + local pwrLabel = getOptionLabel(maxPower.options, maxPower.value or 0) + local name = "TX Power (" .. pwrLabel + if dynamic and (dynamic.value or 0) > 0 then + local dynLabel = getOptionLabel(dynamic.options, dynamic.value) + name = name .. " " .. dynLabel + end + name = name .. ")" + txPwrFolder.dynName = name + end + + -- VTX Administrator folder (id=10): children Band (id=11), Channel (id=12), + -- Pwr Lvl (id=13), Pitmode (id=14) + local vtxFolder = findParam(device, 10) + local vtxBand = findParam(device, 11) + local vtxChan = findParam(device, 12) + local vtxPwr = findParam(device, 13) + local vtxPit = findParam(device, 14) + if vtxFolder and vtxBand then + local bandVal = vtxBand.value or 0 + if bandVal == 0 then + -- Band is "Off" -> use static name (no dynamic suffix) + vtxFolder.dynName = nil + else + local bandLabel = getOptionLabel(vtxBand.options, bandVal) + local chanLabel = tostring((vtxChan and vtxChan.value) or 1) + local name = "VTX Admin (" .. bandLabel .. ":" .. chanLabel + + local pwrVal = (vtxPwr and vtxPwr.value) or 0 + if pwrVal > 0 then + local pwrLabel = getOptionLabel(vtxPwr.options, pwrVal) + name = name .. ":" .. pwrLabel + + local pitVal = (vtxPit and vtxPit.value) or 0 + if pitVal == 1 then + name = name .. ":P" + elseif pitVal > 1 then + local pitLabel = getOptionLabel(vtxPit.options, pitVal) + name = name .. ":" .. pitLabel + end + end + + name = name .. ")" + vtxFolder.dynName = name + end + end +end + +-- ============================================================================ +-- Dynamic telemetry bandwidth (mirrors TXModuleParameters.cpp updateTlmBandwidth) +-- ============================================================================ + +--- Convert a TLM ratio option index to its divisor value. +-- Matches firmware TLMratioEnumToValue(). +-- Options: 0=Std, 1=Off, 2=1:128, 3=1:64, 4=1:32, 5=1:16, 6=1:8, 7=1:4, 8=1:2, 9=Race +-- @param enumval option index (0-based) +-- @return divisor integer (e.g. 128, 64, 32, …) +local function tlmRatioEnumToValue(enumval) + if enumval <= 1 then + return 1 + end -- Std/Off -> 1 (caller handles display) + if enumval >= 9 then + return 1 + end -- Race -> same as Std + -- 2=1:128 -> 128, 3=1:64 -> 64, … 8=1:2 -> 2 + -- Formula: 2^(8 + 1 - enumval) (matching firmware: 1 << (8 + TLM_RATIO_NO_TLM - enumval)) + return math.floor(2 ^ (9 - enumval)) +end + +--- Compute TLM burst max for a given rate and ratio divisor. +-- Matches firmware TLMBurstMaxForRateRatio(). +-- @param rateHz packet rate in Hz +-- @param ratioDiv ratio divisor (e.g. 128, 64, …) +-- @return burst count (>= 1) +local function tlmBurstMaxForRateRatio(rateHz, ratioDiv) + local retVal = math.floor(512 * rateHz / ratioDiv / 1000) + if retVal > 1 then + retVal = retVal - 1 + else + retVal = 1 + end + return retVal +end + +--- Update the Telem Ratio units field to show bandwidth or default ratio. +-- Mirrors firmware updateTlmBandwidth() from TXModuleParameters.cpp. +-- @param device the device table (txDevice) +local function updateTlmBandwidth(device) + local packetRate = findParam(device, 1) -- Packet Rate + local telemRatio = findParam(device, 2) -- Telem Ratio + local switchMode = findParam(device, 3) -- Switch Mode + if not packetRate or not telemRatio then + return + end + + local rateIdx = packetRate.value or 0 + local rateCfg = rateConfigs[rateIdx] + if not rateCfg then + return + end + + local tlmVal = telemRatio.value or 0 + + -- Std (0) or Race (9): display the rate's default ratio + if tlmVal == 0 or tlmVal == 9 then + local defaultDiv = tlmRatioEnumToValue(rateCfg.defaultTlm) + telemRatio.units = " (1:" .. defaultDiv .. ")" + return + end + + -- Off (1): empty units + if tlmVal == 1 then + telemRatio.units = "" + return + end + + -- Specific ratio (2-8): compute bandwidth in bps + local hz = rateCfg.hz + local ratioDiv = tlmRatioEnumToValue(tlmVal) + local burst = tlmBurstMaxForRateRatio(hz, ratioDiv) + + -- Wide mode (value=1) uses 8ch/fullres OTA -> 10 bytes per call + -- Hybrid mode (value=0) uses 4ch/std OTA -> 5 bytes per call + local isFullRes = switchMode and (switchMode.value or 0) == 1 + local bytesPerCall = isFullRes and 10 or 5 + + local bandwidth = math.floor(bytesPerCall * 8 * burst * hz / ratioDiv / (burst + 1)) + + -- FullRes correction: extra bandwidth from telemetry packed into LinkStats packet + -- sizeof(OTA_LinkStats_s) = 4 bytes + if isFullRes then + bandwidth = bandwidth + 8 * (10 - 4) + end + + telemRatio.units = " (" .. bandwidth .. "bps)" +end + +-- Set initial dynamic folder names based on default parameter values +updateFolderNames(txDevice) +-- Set initial telemetry bandwidth display +updateTlmBandwidth(txDevice) + +-- ============================================================================ +-- Scenario State +-- ============================================================================ + +-- Reconnect scenario timing +local reconnectDelay = 500 -- ~5 seconds (getTime() ticks at 10ms) +local startTime = nil -- set on first mockPush/mockPop call + +-- Dynamic RX availability (replaces static hasRxDevice boolean) +local function isRxAvailable() + if config.scenario == "reconnect" then + if not startTime then + return false + end + return getTime() - startTime >= reconnectDelay + end + return config.scenario ~= "disconnected" +end + +-- ELRS Lua flag bits (from TXModuleEndpoint.h): +-- bit 0: LUA_FLAG_CONNECTED +-- bit 1: LUA_FLAG_STATUS1 +-- bit 2: LUA_FLAG_MODEL_MATCH (warning) +-- bit 3: LUA_FLAG_ISARMED (warning) +-- bit 4: LUA_FLAG_WARNING1 +-- bit 5: LUA_FLAG_ERROR_CONNECTED (critical) +-- bit 6: LUA_FLAG_ERROR_BAUDRATE (critical) +local function getElrsFlags() + if config.scenario == "reconnect" then + return isRxAvailable() and 0x01 or 0x00 + elseif config.scenario == "model_mismatch" then + return 0x05 -- connected + model mismatch + elseif config.scenario == "armed" then + return 0x09 -- connected + armed + elseif config.scenario == "normal" then + return 0x01 -- connected + else + return 0x00 -- disconnected + end +end + +local function getElrsFlagsInfo() + if config.scenario == "model_mismatch" then + return "Model Mismatch" + elseif config.scenario == "armed" then + return "is Armed!" + end + return "" +end + +-- ============================================================================ +-- Command state machine (per-parameter) +-- ============================================================================ + +local commandStates = {} -- keyed by "deviceId:paramId" + +local function getCommandKey(deviceId, paramId) + return tostring(deviceId) .. ":" .. tostring(paramId) +end + +-- Number of CMD_QUERY polls a command stays in CMD_EXECUTING before completing. +-- Keep low for snappy simulator testing; real hardware controls its own timing. +local COMMAND_EXECUTE_POLLS = 1 + +local function handleCommandWrite(device, param, newStatus) + local key = getCommandKey(device.id, param.id) + if not commandStates[key] then + commandStates[key] = { status = CRSF.CMD_IDLE, info = "" } + end + local state = commandStates[key] + + if newStatus == CRSF.CMD_CLICK or newStatus == CRSF.CMD_CONFIRMED then + local needsConfirm = param.persistent and config.scenario == "normal" + if newStatus == CRSF.CMD_CLICK and needsConfirm then + -- WiFi/BLE commands ask for confirmation only when connected (scenario "normal") + state.status = CRSF.CMD_ASKCONFIRM + state.info = "Confirm " .. param.name .. "?" + else + -- Go straight to executing (matches real ELRS firmware behavior: + -- most commands skip confirmation and execute immediately) + state.status = CRSF.CMD_EXECUTING + state.info = "Executing..." + if param.persistent then + state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi) + else + state.queriesRemaining = COMMAND_EXECUTE_POLLS + end + end + elseif newStatus == CRSF.CMD_CANCEL then + state.status = CRSF.CMD_IDLE + state.info = "" + elseif newStatus == CRSF.CMD_QUERY then + -- Advance executing commands toward completion. + -- Commands with queriesRemaining = nil run indefinitely until cancelled. + if state.status == CRSF.CMD_EXECUTING and state.queriesRemaining then + state.queriesRemaining = state.queriesRemaining - 1 + if state.queriesRemaining <= 0 then + state.status = CRSF.CMD_IDLE + state.info = "Complete" + end + end + end + + -- Update the param for encoding + param.status = state.status + param.info = state.info +end + +-- ============================================================================ +-- mockPush: Processes commands sent by the Lua script +-- ============================================================================ + +local function mockPush(command, data) + if not startTime then + startTime = getTime() + end + + if command == CRSF.FRAMETYPE_DEVICE_PING then + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + + -- TX module responds immediately (local to handset) + queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, destAddr)) + + -- RX device responds with delay (relayed over air link) + -- Uses deferred delivery so it arrives in the next poll cycle, + -- after the TX DEVICE_INFO has been processed + if isRxAvailable() then + queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, destAddr)) + end + return true + + elseif command == CRSF.FRAMETYPE_PARAMETER_READ then + -- Parameter read request: data = { deviceId, handsetId, fieldId, chunk } + local deviceId = data[1] + local fieldId = data[3] + local chunk = data[4] or 0 + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + + local device = findDeviceByAddr(deviceId) + if device then + local param + if fieldId == 0 then + -- Field 0 is the root folder (synthetic, not in params list) + param = { id = 0, parent = 0, type = CRSF.FOLDER, name = device.name, _device = device } + else + param = findParam(device, fieldId) + end + if param then + -- Check if there's a command state override + local key = getCommandKey(device.id, param.id) + if commandStates[key] and bit32.band(param.type, 0x7F) == CRSF.COMMAND then + param.status = commandStates[key].status + param.info = commandStates[key].info + end + -- Set device context for folder child ID encoding + param._device = param._device or device + local entry = encodeParameterEntry(device, param, chunk, destAddr) + param._device = nil -- clean up temporary reference + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + end + end + return true + + elseif command == CRSF.FRAMETYPE_PARAMETER_WRITE then + -- Parameter write: data = { deviceId, handsetId, fieldId, value/status } + local deviceId = data[1] + local fieldId = data[3] + local writeValue = data[4] + + -- Special case: ELRS status request (fieldId == 0) + if fieldId == 0 then + local flags = getElrsFlags() + local flagsInfo = getElrsFlagsInfo() + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + queuePush(CRSF.FRAMETYPE_ELRS_STATUS, + encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) + return true + end + + local device = findDeviceByAddr(deviceId) + if device then + local param = findParam(device, fieldId) + if param then + local t = bit32.band(param.type, 0x7F) + if t == CRSF.COMMAND then + -- Command: handle state machine + handleCommandWrite(device, param, writeValue) + -- Queue the updated parameter entry as response + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, + encodeParameterEntry(device, param, 0, destAddr)) + else + -- Value write: update the stored value immediately (matches + -- firmware config.Set*() which stores in RAM right away). + param.value = writeValue + -- Defer folder name and bandwidth updates to the next poll cycle. + -- Real firmware runs updateFolderNames() in the event loop, not + -- in the PARAMETER_WRITE handler. No auto-send of parent folder + -- entry either -- the Lua script must explicitly PARAMETER_READ. + folderNamesReadyAt = getTime() + FOLDER_NAMES_UPDATE_TICKS + folderNamesDevice = device + end + end + end + return true + end + + -- Unknown command - ignore + return true +end + +-- ============================================================================ +-- mockPop: Returns next queued packet or nil +-- ============================================================================ + +local function mockPop() + if not startTime then + startTime = getTime() + end + + local command, data = queuePop() + + -- Apply deferred folder name updates once enough real time has elapsed. + -- Until then, any PARAMETER_READ for a folder returns the stale dynName. + if folderNamesDevice and getTime() >= folderNamesReadyAt then + updateFolderNames(folderNamesDevice) + updateTlmBandwidth(folderNamesDevice) + folderNamesDevice = nil + end + + return command, data +end + +-- ============================================================================ +-- Module found depends on scenario +-- ============================================================================ + +local moduleFound = (config.scenario ~= "no_module") + +-- ============================================================================ +-- Mock Telemetry Sensor Values +-- Per-scenario base values keyed by EdgeTX sensor ID (as used by getSensorValue()). +-- no_module scenario has no entry -> mockTelemetry returns nil. +-- ============================================================================ + +-- TX-side sensors reported via CRSF link statistics regardless of RX connection. +-- On real hardware the TX module always sends these; the simulator mirrors that. +local txModuleTelemetry = { TPWR = 50, RFMD = 7 } + +local scenarioTelemetry = { + normal = { + TPWR = 50, RFMD = 7, + ["1RSS"] = -87, ["2RSS"] = -93, + RQly = 99, ANT = 1, + RxBt = 15.2, Curr = 12.5, + FM = "ACRO", Sats = 12, GSpd = 25.3, Alt = 142, + }, + armed = { + TPWR = 250, RFMD = 7, + ["1RSS"] = -78, ["2RSS"] = -82, + RQly = 100, ANT = 0, + RxBt = 14.8, Curr = 28.5, + FM = "ACRO", Sats = 14, GSpd = 42.7, Alt = 85, + }, + model_mismatch = { + TPWR = 50, RFMD = 7, + ["1RSS"] = -90, ["2RSS"] = -95, + RQly = 95, ANT = 1, + RxBt = 15.8, Curr = 0.5, + }, + reconnect = { + -- Same as normal; only served when isRxAvailable() is true + TPWR = 50, RFMD = 7, + ["1RSS"] = -87, ["2RSS"] = -93, + RQly = 99, ANT = 1, + RxBt = 15.2, Curr = 12.5, + }, +} + +-- Jitter ranges for sensors that fluctuate in real life. +-- Sensors not listed (TPWR, RFMD, ANT, FM, Sats) stay static. +local sensorJitter = { + ["1RSS"] = 3, -- +/- 3 dBm + ["2RSS"] = 3, + RQly = 2, -- +/- 2% + RxBt = 0.05, -- +/- 0.05V + Curr = 2.0, -- +/- 2A + GSpd = 3.0, + Alt = 5, +} + +-- Telemetry values are cached and only refreshed once per second to match +-- realistic sensor update rates and avoid excessive CPU in the simulator. +local telemetryCache = {} +local lastTelemetryUpdate = 0 +local TELEMETRY_UPDATE_TICKS = 100 -- 100 ticks = 1 second (getTime() at 10ms/tick) + +local function updateTelemetryCache() + local now = getTime() + if now - lastTelemetryUpdate < TELEMETRY_UPDATE_TICKS then + return + end + lastTelemetryUpdate = now + + if not isRxAvailable() then + -- TX module still reports RFMD/TPWR via link stats even without RX. + -- Only provide these when a module is present (not no_module). + telemetryCache = {} + if moduleFound then + for k, v in pairs(txModuleTelemetry) do + telemetryCache[k] = v + end + end + return + end + local t = scenarioTelemetry[config.scenario] + if not t then + telemetryCache = {} + return + end + + telemetryCache = {} + for sensorId, base in pairs(t) do + local jit = sensorJitter[sensorId] + if jit then + local val = base + (math.random() * 2 - 1) * jit + if jit == math.floor(jit) then + val = math.floor(val + 0.5) + end + telemetryCache[sensorId] = val + else + telemetryCache[sensorId] = base + end + end +end + +--- Return a mock telemetry sensor value for the current scenario. +-- Called at ~10 Hz by the widget via crsf.getSensorValue(). Values are +-- regenerated only once per second; intermediate calls return cached data. +-- Returns nil when disconnected or the sensor is not defined. +local function mockGetSensorValue(sensorId) + updateTelemetryCache() + return telemetryCache[sensorId] +end + +-- ============================================================================ +-- Return mock interface +-- ============================================================================ + +return { + pop = mockPop, + push = mockPush, + moduleFound = moduleFound, + getSensorValue = mockGetSensorValue, +} diff --git a/color/SCRIPTS/ELRSLib/crsf.lua b/color/SCRIPTS/ELRSLib/crsf.lua new file mode 100644 index 0000000..a524dd1 --- /dev/null +++ b/color/SCRIPTS/ELRSLib/crsf.lua @@ -0,0 +1,340 @@ +--------------------------------------------------------------------------- +-- CRSF Protocol Singleton -- +-- -- +-- Allows multiple widgets to share a single CRSF connection. -- +-- crossfireTelemetryPop() is a destructive queue — each frame can only -- +-- be read once. This singleton is the sole consumer: it drains the -- +-- queue in poll() and fans each frame out to every widget that -- +-- registered a handler for that frame type. Widgets never call -- +-- crossfireTelemetryPop() directly; they register callbacks via -- +-- registerHandler() and push outgoing frames through CRSF.push(). -- +-- -- +-- Loaded once via loadScript() from /SCRIPTS/ELRSLib/crsf.lua. -- +-- Returns a table with protocol constants, handler registry, -- +-- pop queue dispatcher, and device info cache. -- +--------------------------------------------------------------------------- + +local CRSF = {} + +-- ============================================================================ +-- Named protocol constants (no magic numbers anywhere) +-- ============================================================================ + +CRSF.CONST = { + -- Addresses + ADDRESS_TX_MODULE = 0xEE, + ADDRESS_HANDSET = 0xEF, + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses) + FIELD_UINT8 = 0, + FIELD_INT8 = 1, + FIELD_UINT16 = 2, + FIELD_INT16 = 3, + FIELD_FLOAT = 8, + FIELD_TEXT_SELECTION = 9, + FIELD_STRING = 10, + FIELD_FOLDER = 11, + FIELD_INFO = 12, + FIELD_COMMAND = 13, + + -- Command states + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_CONFIRMED = 3, + + -- Folder child list terminator + FIELD_LIST_END = 0xFF, + + -- Module type for model.getModule() check + MODULE_TYPE_CROSSFIRE = 5, +} + +-- ============================================================================ +-- Internal state +-- ============================================================================ + +-- Handler registry: frameType -> { callback1, callback2, ... } +CRSF._handlers = {} + +-- Device info cache (populated by built-in DEVICE_INFO handler) +CRSF.deviceInfo = {} + +-- RX connection state (populated by built-in ELRS_STATUS handler) +CRSF.rxConnected = false +CRSF.modelMismatch = false +CRSF.elrsFlags = 0 +CRSF.elrsFlagsInfo = "" + +-- Tick guard for poll() +CRSF._lastPollTick = 0 + +-- Device info polling +CRSF._lastDevPoll = 0 + +-- ELRS status polling +CRSF._lastStatusPoll = 0 + +-- ============================================================================ +-- Default telemetry wrappers: delegate to real EdgeTX functions +-- When mocking is active, setMock() replaces these with mock implementations +-- ============================================================================ + +function CRSF.pop() + return crossfireTelemetryPop() +end + +function CRSF.push(command, data) + return crossfireTelemetryPush(command, data) +end + +function CRSF.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == CRSF.CONST.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +-- Field ID cache (string sensor name -> numeric ID) +CRSF._vCache = {} + +--- Read a telemetry sensor value by name. +-- Caches the getFieldInfo string->ID lookup; getValue is called every time. +-- setMock() replaces this function with the simulator's mock telemetry. +function CRSF.getSensorValue(id) + local cid = CRSF._vCache[id] + if cid == nil then + local info = getFieldInfo(id) + cid = info and info.id or 0 + CRSF._vCache[id] = cid + end + return cid ~= 0 and getValue(cid) or nil +end + +-- ============================================================================ +-- Simulator integration (mirrors expresslrs.lua setMock pattern) +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + CRSF.pop = mock.pop + CRSF.push = mock.push + CRSF.hasCrsfModule = function() return mock.moduleFound end + CRSF.getSensorValue = mock.getSensorValue +end + +setMock() + +-- ============================================================================ +-- Handler registry +-- ============================================================================ + +--- Register a callback for a specific CRSF frame type. +-- @param frameType numeric frame type (use CRSF.CONST.FRAMETYPE_*) +-- @param callback function(data) called when a frame of this type is popped +function CRSF:registerHandler(frameType, callback) + if not self._handlers[frameType] then + self._handlers[frameType] = {} + end + -- Avoid duplicate registration + for _, cb in ipairs(self._handlers[frameType]) do + if cb == callback then + return + end + end + self._handlers[frameType][#self._handlers[frameType] + 1] = callback +end + +--- Remove a previously registered callback. +-- @param frameType numeric frame type +-- @param callback the exact function reference to remove +function CRSF:unregisterHandler(frameType, callback) + local handlers = self._handlers[frameType] + if not handlers then + return + end + for i = #handlers, 1, -1 do + if handlers[i] == callback then + table.remove(handlers, i) + return + end + end +end + +-- ============================================================================ +-- Pop queue dispatcher +-- ============================================================================ + +--- Drain the pop queue and dispatch frames to registered handlers. +-- Guarded by a tick timestamp so only one effective poll runs per tick, +-- even if multiple widgets call this. +function CRSF:poll() + local now = getTime() + if now == self._lastPollTick then + return + end + self._lastPollTick = now + + while true do + local command, data = CRSF.pop() + if command == nil then + break + end + local fh = self._handlers[command] + if fh then + for _, cb in ipairs(fh) do + cb(data) + end + end + end +end + +-- ============================================================================ +-- Shared helpers +-- ============================================================================ + +--- Parse a null-terminated string from a CRSF data array. +-- Modifies data in-place (bytes -> chars) for efficiency. +-- @param data array of byte values +-- @param off 1-based start offset +-- @return string, nextOffset +function CRSF:fieldGetString(data, off) + local startOff = off + while data[off] ~= 0 do + data[off] = string.char(data[off]) + off = off + 1 + end + return table.concat(data, nil, startOff, off - 1), off + 1 +end + +--- Send a DEVICE_PING if device info is not yet available. +-- Rate-limited to at most once per second. +function CRSF:requestDeviceInfo() + if self.deviceInfo.name then + return + end + local now = getTime() + if now - self._lastDevPoll < 100 then + return + end + self._lastDevPoll = now + CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, + { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER }) +end + +--- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). +-- Updates rxConnected via the ELRS_STATUS handler on the next poll(). +-- Rate-limited to at most once per second. +function CRSF:requestElrsStatus() + local now = getTime() + if now - (self._lastStatusPoll or 0) < 100 then + return + end + self._lastStatusPoll = now + CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, + { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) +end + +-- ============================================================================ +-- Built-in handlers +-- ============================================================================ + +-- DEVICE_INFO handler: parses and caches module name, version, RFMOD/RFRSSI +local function onDeviceInfo(data) + if data[2] ~= CRSF.CONST.ADDRESS_TX_MODULE then + return + end + + local name, off = CRSF:fieldGetString(data, 3) + local info = CRSF.deviceInfo + info.name = name + -- off points past null terminator of name + -- serNo (4 bytes) + hwVer (4 bytes) + swVer (4 bytes) = 12 bytes + -- swVer is at off+8..off+11, but version fields are at specific offsets: + info.vMaj = data[off + 9] + info.vMin = data[off + 10] + info.vRev = data[off + 11] + info.vStr = string.format("%s (%d.%d.%d)", info.name, info.vMaj, info.vMin, info.vRev) + + -- RFMOD / RFRSSI lookup tables (version-dependent) + if info.vMaj == 4 then + info.RFMOD = { + "25Hz", "50Hz", "100Hz", "100HzFull", "150Hz", "200Hz", "200HzFull", + "250Hz", "333HzFull", "500Hz", "D50", "K1000Full", + [21]="25Hz", [22]="50Hz", [23]="100Hz", [24]="100HzFull", + [25]="150Hz", [26]="200Hz", [27]="200HzFull", [28]="250Hz", + [29]="333HzFull", [30]="500Hz", + [31]="D250", [32]="D500", [33]="F500", [34]="F1000", + [35]="DK250", [36]="DK500", [37]="K1000", + [101]="X100Full", [102]="X150", + } + info.RFRSSI = { + -123, -120, -117, -112, 0, -112, -111, -111, 0, 0, -112, -101, + [21]=0, [22]=-115, [23]=0, [24]=-112, [25]=-112, [26]=0, [27]=0, + [28]=-108, [29]=-105, [30]=-105, + [31]=-104, [32]=-104, [33]=-104, [34]=-104, + [35]=-103, [36]=-103, [37]=-103, + [101]=-112, [102]=-112, + } + elseif info.vMaj == 3 then + info.RFMOD = { + "", "25Hz", "50Hz", "100Hz", "100HzFull", "150Hz", "200Hz", "250Hz", + "333HzFull", "500Hz", "D250", "D500", "F500", "F1000", + "D50", "200HzFull", "DK500", "K1000", "9K1000", "K1000Full", + } + info.RFRSSI = { + 0, -123, -115, -117, -112, -112, -112, -108, -105, -105, + -104, -104, -104, -104, -112, -111, -103, -103, 0, -101, + } + else + info.RFMOD = { "", "25Hz", "50Hz", "100Hz", "150Hz", "200Hz", "250Hz", "500Hz" } + info.RFRSSI = { 0, -123, -115, -117, -112, -112, -108, -105 } + end +end + +-- ELRS_STATUS handler: updates rxConnected, modelMismatch, elrsFlagsInfo +local function onElrsStatus(data) + CRSF.elrsFlags = data[6] or 0 + CRSF.rxConnected = bit32.btest(CRSF.elrsFlags, 1) + CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) + + -- Parse null-terminated warning info string starting at data[7] + local parts = {} + local off = 7 + while data[off] and data[off] ~= 0 do + parts[#parts + 1] = string.char(data[off]) + off = off + 1 + end + CRSF.elrsFlagsInfo = table.concat(parts) +end + +-- Register built-in handlers +CRSF:registerHandler(CRSF.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) +CRSF:registerHandler(CRSF.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return CRSF diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua new file mode 100644 index 0000000..0d13f1a --- /dev/null +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -0,0 +1,1807 @@ +-- TNS|ExpressLRS LVGL|TNE +---- ######################################################################### +---- # # +---- # Copyright (C) OpenTX, adapted for ExpressLRS # +-----# # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- # # +---- # LVGL version for EdgeTX 2.11.4+ # +---- ######################################################################### +local VERSION = "r1 LVGL" +local VERSION_CHECK_ENABLED = true + +-- ============================================================================ +-- App Module: Application state and coordinators +-- ============================================================================ + +local App = { + -- Module check + crsfModuleChecked = false, + crsfModuleFound = false, + + -- User acknowledgment + warningDismissed = false, + + -- Active dialogs + warningDialog = nil, + commandDialog = nil, + + -- Exit + shouldExit = false, +} + +-- Forward declarations for modules (needed for cross-references) +local Navigation +local Protocol +local UI +local Dialogs + +-- Reset App to initial values +function App.reset() + App.crsfModuleChecked = false + App.crsfModuleFound = false + App.warningDismissed = false + App.shouldExit = false + Protocol.reset() +end + +-- Module check (caches result to avoid repeated checks) +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + + App.crsfModuleChecked = true + App.crsfModuleFound = Protocol.hasCrsfModule() + return App.crsfModuleFound +end + +-- Coordinator: changes device and triggers UI update +-- When switching to a DIFFERENT device (user action), pushes a device entry +-- onto the navigation stack so the user can navigate back. +-- When the SAME device is updated (initial setup), just resets navigation. +function App.changeDevice(devId) + local device = Protocol.getDevice(devId) + if not device then + return + end + local prevDeviceId = Protocol.deviceId + local isSwitching = (prevDeviceId ~= devId) + if Protocol.setDevice(device) then + if isSwitching then + Navigation.openDevice(device.name, prevDeviceId) + else + Navigation.reset() + end + return UI.invalidate() + end +end + +-- Coordinator: opens a folder, loads its children, and refreshes UI +function App.openFolder(folderId, folderName) + Protocol.flushPendingSaves() + Navigation.openFolder(folderId, folderName) + Protocol.loadFolderChildren(folderId) + return UI.invalidate() +end + +-- Back button handler: navigate back or show exit confirmation +function App.handleBack() + Protocol.flushPendingSaves() + if Navigation.isAtRoot() then + Dialogs.showConfirm({ + title = "Exit", + message = "Exit ExpressLRS Lua script?", + onConfirm = function() App.shouldExit = true end + }) + else + local entry = Navigation.goBack() + if entry and entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + UI.invalidate() + end +end + +-- ============================================================================ +-- Navigation Module: Folder navigation stack and methods +-- ============================================================================ + +Navigation = { + stack = {}, + -- Navigation entry type constants (integers to save RAM vs strings) + TYPE_FOLDER = 0, + TYPE_DEVICE = 1, + -- Synthetic folder IDs + FOLDER_OTHER_DEVICES = -1, +} + +function Navigation.getCurrent() + local top = Navigation.stack[#Navigation.stack] + return top and top.id or nil -- nil if at root (or device root) +end + +function Navigation.isAtRoot() + return #Navigation.stack == 0 +end + +-- Check if the user has navigated into a device (for hiding "Other Devices") +function Navigation.hasDeviceEntry() + for _, entry in ipairs(Navigation.stack) do + if entry.type == Navigation.TYPE_DEVICE then + return true + end + end + return false +end + +function Navigation.openFolder(folderId, folderName) + local baseName = folderName + if folderName then + baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName + end + table.insert(Navigation.stack, { type = Navigation.TYPE_FOLDER, id = folderId, name = baseName }) +end + +function Navigation.openDevice(deviceName, prevDeviceId) + -- id=nil means device root; getCurrent() returns nil which shows root fields + table.insert(Navigation.stack, { type = Navigation.TYPE_DEVICE, id = nil, name = deviceName, prevDeviceId = prevDeviceId }) +end + +function Navigation.goBack() + if #Navigation.stack > 0 then + return table.remove(Navigation.stack) + end + return nil +end + +function Navigation.reset() + Navigation.stack = {} +end + +-- ============================================================================ +-- Protocol Module: CRSF constants, parsing, and field operations +-- ============================================================================ + +Protocol = { + -- EdgeTX module type for CRSF/ELRS + MODULE_TYPE_CROSSFIRE = 5, + + -- CRSF Field Type Constants + CRSF = { + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + UINT32 = 4, + INT32 = 5, + UINT64 = 6, + INT64 = 7, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + -- Internal/extended types (not in official CRSF protocol) + BACK_EXIT = 14, + DEVICE = 15, + DEVICE_FOLDER = 16, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- ELRS flags: bits 0-1 are status (connected, status1), + -- bits 2-4 are warnings (model match, armed, warning1), + -- bits 5-7 are critical errors (error connected, error baudrate, critical2) + ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only + ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags + + -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, + }, + + -- Handlers dispatch table (populated after function definitions) + handlers = {}, + + -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua + deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) + handsetId = 0xEF, -- ADDRESS_ELRS_LUA + deviceName = nil, + deviceIsELRS_TX = nil, + + -- Fields collection + fields = {}, + fieldsCount = 0, + fieldPopup = nil, + + -- Devices collection + devices = {}, + devicesRefreshTimeout = 50, + + -- Status/flags (parsed from ELRS info messages) + elrsFlags = 0, + elrsFlagsInfo = "", + receivedPackets = nil, + lostPackets = nil, + + -- Protocol timing + linkstatTimeout = 100, + + -- Communication state + fieldTimeout = 0, + fieldChunk = 0, + fieldData = nil, + loadQueue = {}, + expectChunksRemain = -1, + backgroundLoading = false, + + -- Debounce: deferred saves for continuous controls (numberEdit) + DEBOUNCE_SAVE_DELAY = 30, -- 300ms in getTime() ticks (10ms each) + pendingSaves = {}, -- keyed by field.id: { field, timeout } + + -- Connection transition tracking (for auto-discovery on reconnect) + wasConnected = false, +} + +-- Reset Protocol state +function Protocol.reset() + -- Device identity + Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER + Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA + Protocol.deviceName = nil + Protocol.deviceIsELRS_TX = nil + + -- Fields collection + Protocol.fields = {} + Protocol.fieldsCount = 0 + Protocol.fieldPopup = nil + + -- Devices collection + Protocol.devices = {} + Protocol.devicesRefreshTimeout = 50 + + -- Status/flags + Protocol.elrsFlags = 0 + Protocol.elrsFlagsInfo = "" + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + + -- Protocol timing + Protocol.linkstatTimeout = 100 + + -- Communication state + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + Protocol.expectChunksRemain = -1 + Protocol.backgroundLoading = false + Protocol.pendingSaves = {} + Protocol.wasConnected = false +end + +-- Default telemetry wrappers: delegate to real EdgeTX functions +-- When mocking is active, setMock() replaces these with mock implementations +function Protocol.pop() + return crossfireTelemetryPop() +end + +function Protocol.push(command, data) + return crossfireTelemetryPush(command, data) +end + +-- Check connection state from elrsFlags +function Protocol.isConnected() + return bit32.btest(Protocol.elrsFlags, 1) +end + +-- Response timeout for PARAMETER_READ (from upstream elrsV3.lua): +-- 0.5s for local TX module, 5s for remote devices relayed over air link. +function Protocol.fieldResponseTimeout() + return Protocol.deviceIsELRS_TX and 50 or 500 +end + +-- Check if a CRSF-compatible module is available +function Protocol.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +-- Set active device and prepare fields +-- Returns true if device changed, false if no change needed +function Protocol.setDevice(device) + if not device then + return false + end + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fldcnt then + return false + end + + Protocol.deviceId = device.id + Protocol.elrsFlags = 0 + Protocol.deviceName = device.name + Protocol.fieldsCount = device.fldcnt + Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + + Protocol.allocateFields() + Protocol.reloadAllFields() + return true +end + +-- ============================================================================ +-- Protocol: Field management functions +-- ============================================================================ + +function Protocol.allocateFields() + Protocol.fields = {} + Protocol.fields[0] = {} -- root folder (field 0) + for i = 1, Protocol.fieldsCount do + Protocol.fields[i] = {} + end +end + +-- Check if all children of a folder have been loaded (have names). +-- folderId: the folder's field ID, or nil for root (uses field 0). +function Protocol.isFolderLoaded(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return false + end + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if not child or not child.name or child.nameStale then + return false + end + end + return true +end + +-- Return load progress for a folder's children as (loaded, total). +-- Returns nil if the folder or its children list is unknown yet. +function Protocol.getFolderLoadProgress(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return nil + end + local total = #folder.children + local loaded = 0 + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name then + loaded = loaded + 1 + end + end + return loaded, total +end + +function Protocol.reloadAllFields() + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + -- Start by loading only field 0 (root folder). + -- Its response contains child IDs; only root children are auto-queued. + -- Subfolder children are loaded on-demand via loadFolderChildren(). + Protocol.loadQueue[1] = 0 +end + +-- Parameterized: takes folderId instead of accessing Navigation +function Protocol.getFieldsInFolder(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return {} + end + local result = {} + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name and not child.hidden then + result[#result + 1] = child + end + end + return result +end + +function Protocol.getDevice(id) + for _, device in ipairs(Protocol.devices) do + if device.id == id then + return device + end + end +end + +function Protocol.reloadCurField(field) + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id +end + +-- Queue unloaded children of a folder for on-demand loading. +-- Called when the user navigates into a subfolder whose contents +-- haven't been fetched yet. +function Protocol.loadFolderChildren(folderId) + local folder = Protocol.fields[folderId] + if not folder or not folder.children then + return + end + for i = #folder.children, 1, -1 do + local childId = folder.children[i] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- Queue all unloaded subfolder children for background preloading. +-- Called once after the root screen finishes loading so that navigating +-- into subfolders is instant (or near-instant). +function Protocol.startBackgroundLoad() + Protocol.backgroundLoading = true + for i = 1, #Protocol.fields do + local field = Protocol.fields[i] + if field.type == Protocol.CRSF.FOLDER and field.children then + for j = #field.children, 1, -1 do + local childId = field.children[j] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- ============================================================================ +-- Protocol: Field data helpers +-- ============================================================================ + +function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) + local r = last or (isOpts and {}) + local optParts = {} + local vcnt = 0 + repeat + local b = data[offset] + offset = offset + 1 + + if not last then + if r and (b == 59 or b == 0) then + r[#r + 1] = table.concat(optParts) + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + elseif b ~= 0 then + -- Translate legacy OpenTX arrow bytes (0xC0/0xC1) from ELRS firmware + -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs + optParts[#optParts + 1] = ({ + [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), + [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) + })[b] or string.char(b) + end + end + until b == 0 + + return (r or table.concat(optParts)), offset, vcnt +end + +function Protocol.fieldGetValue(data, offset, size) + local result = 0 + for i = 0, size - 1 do + result = bit32.lshift(result, 8) + data[offset + i] + end + return result +end + +-- ============================================================================ +-- Protocol: Field load functions +-- ============================================================================ + +local function fieldUnsignedLoad(field, data, offset, size, unitoffset) + field.value = Protocol.fieldGetValue(data, offset, size) + field.min = Protocol.fieldGetValue(data, offset + size, size) + field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) + field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + if size ~= 1 then + field.size = size + end +end + +local function fieldUnsignedToSigned(field, size) + local bandval = bit32.lshift(0x80, (size - 1) * 8) + field.value = field.value - bit32.band(field.value, bandval) * 2 + field.min = field.min - bit32.band(field.min, bandval) * 2 + field.max = field.max - bit32.band(field.max, bandval) * 2 +end + +local function fieldSignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedToSigned(field, size) + field.size = -size +end + +function Protocol.fieldIntLoad(field, data, offset) + local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad + return loadFn(field, data, offset, math.floor(field.type / 2) + 1) +end + +function Protocol.fieldFloatLoad(field, data, offset) + fieldSignedLoad(field, data, offset, 4, 21) + field.prec = data[offset + 16] + if field.prec > 3 then + field.prec = 3 + end + field.step = Protocol.fieldGetValue(data, offset + 17, 4) + field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit + field.prec = 10 ^ field.prec +end + +function Protocol.fieldTextSelLoad(field, data, offset) + local vcnt + local cached = field.dirty == nil and field.values + field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) + if not cached then + field.disabled = vcnt <= 1 + end + field.value = data[offset] + field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + field.dirty = nil +end + +function Protocol.fieldStringLoad(field, data, offset) + field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) + if #data >= offset then + field.maxlen = data[offset] + end +end + +function Protocol.fieldCommandLoad(field, data, offset) + field.status = data[offset] + field.timeout = data[offset + 1] + field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) + if field.status == Protocol.CRSF.CMD_IDLE then + Protocol.fieldPopup = nil + end +end + +function Protocol.fieldFolderLoad(field, data, offset) + -- Parse child ID list (terminated by 0xFF) + field.children = {} + while data[offset] and data[offset] ~= 0xFF do + field.children[#field.children + 1] = data[offset] + offset = offset + 1 + end +end + +-- ============================================================================ +-- Protocol: Field save functions +-- ============================================================================ + +function Protocol.fieldIntSave(field) + local value = field.value + local size = field.size or 1 + if size < 0 then + size = -size + if value < 0 then + value = bit32.lshift(0x100, (size - 1) * 8) + value + end + end + + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + for i = size - 1, 0, -1 do + frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 + end + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + +-- ============================================================================ +-- Protocol: Related fields reload (for value changes) +-- ============================================================================ + +function Protocol.reloadParentFolder(field) + if field.parent and Protocol.fields[field.parent] then + -- Mark stale instead of clearing name to nil. This keeps the old folder + -- name visible in the UI while the reload is in-flight, avoiding a gap + -- where the folder disappears (getFieldsInFolder skips nil-named fields). + Protocol.fields[field.parent].nameStale = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent + -- Delay the READ so it doesn't race the WRITE on the CRSF bus. + -- Firmware needs time to process the write and update folder names. + -- Uses the same device-dependent timeout as poll() PARAMETER_READ. + local minTimeout = getTime() + Protocol.fieldResponseTimeout() + if Protocol.fieldTimeout < minTimeout then + Protocol.fieldTimeout = minTimeout + end + end +end + +function Protocol.debounceSave(field) + Protocol.pendingSaves[field.id] = { field = field, timeout = getTime() + Protocol.DEBOUNCE_SAVE_DELAY } +end + +function Protocol.flushPendingSaves() + for id, ps in pairs(Protocol.pendingSaves) do + Protocol.pendingSaves[id] = nil + Protocol.fieldIntSave(ps.field) + Protocol.reloadParentFolder(ps.field) + end +end + +function Protocol.reloadRelatedFields(field) + Protocol.reloadParentFolder(field) + + for fieldId = Protocol.fieldsCount, 1, -1 do + local sibling = Protocol.fields[fieldId] + local siblingType = sibling.type or 99 + if fieldId ~= field.id + and sibling.parent == field.parent + and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then + sibling.dirty = true + sibling.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId + end + end + + field.dirty = true + field.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id + Protocol.fieldTimeout = getTime() + 20 + Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 +end + +function Protocol.handleCommandSave(field) + Protocol.reloadCurField(field) + + if field.status ~= nil then + if field.status < Protocol.CRSF.CMD_CONFIRMED then + field.status = Protocol.CRSF.CMD_CLICK + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) + Protocol.fieldPopup = field + Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE + Protocol.fieldTimeout = getTime() + field.timeout + end + end +end + +function Protocol.commandConfirm() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED + end +end + +function Protocol.commandCancel() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.fieldPopup = nil + end +end + +-- ============================================================================ +-- Protocol: Handlers dispatch table (no forward declarations needed!) +-- ============================================================================ + +Protocol.handlers = { + [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT32 + 1] = nil, + [Protocol.CRSF.INT32 + 1] = nil, + [Protocol.CRSF.UINT64 + 1] = nil, + [Protocol.CRSF.INT64 + 1] = nil, + [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, + -- DEVICE and DEVICE_FOLDER are synthetic types, handled by UI directly +} + +-- ============================================================================ +-- Protocol: CRSF message parsing (return signals, no Nav/UI knowledge) +-- ============================================================================ + +function Protocol.parseDeviceInfoMessage(data) + local id = data[2] + local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) + local device = Protocol.getDevice(id) + local isNew = (device == nil) + if isNew then + device = { id = id } + Protocol.devices[#Protocol.devices + 1] = device + end + device.name = newName + device.fldcnt = data[offset + 12] + device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID + + -- Return signal - caller handles device change and navigation + -- shouldChangeDevice: true if this is info about the currently selected device + -- isNewDevice: true if this device was not previously known + local shouldChangeDevice = (Protocol.deviceId == id) + return { shouldChangeDevice = shouldChangeDevice, deviceId = id, isNewDevice = isNew } +end + +function Protocol.parseParameterInfoMessage(data) + local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return false + end + local field = Protocol.fields[fieldId] + local chunksRemain = data[4] + if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then + return false + end + + local offset + if chunksRemain > 0 or Protocol.fieldChunk > 0 then + Protocol.fieldData = Protocol.fieldData or {} + for i = 5, #data do + Protocol.fieldData[#Protocol.fieldData + 1] = data[i] + data[i] = nil + end + offset = 1 + else + Protocol.fieldData = data + offset = 5 + end + + if chunksRemain > 0 then + Protocol.fieldChunk = Protocol.fieldChunk + 1 + Protocol.expectChunksRemain = chunksRemain - 1 + return false + else + Protocol.loadQueue[#Protocol.loadQueue] = nil + + if #Protocol.fieldData > (offset + 2) then + field.id = fieldId + field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil + field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil + local cachedName = (not field.nameStale) and field.name or nil + field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) + field.nameStale = nil + local handler = Protocol.handlers[field.type + 1] + if handler and handler.load then + handler.load(field, Protocol.fieldData, offset) + end + if field.min == 0 then + field.min = nil + end + if field.max == 0 then + field.max = nil + end + + -- Auto-queue children for root folder (field 0) and during background preloading. + -- Subfolder children are otherwise loaded on-demand when user navigates into them. + if field.type == Protocol.CRSF.FOLDER and field.children + and (fieldId == 0 or Protocol.backgroundLoading) then + for i = #field.children, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] + end + end + end + + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + + return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 + end +end + +function Protocol.parseElrsInfoMessage(data) + if data[2] ~= Protocol.deviceId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return + end + + Protocol.lostPackets = data[3] + Protocol.receivedPackets = (data[4] * 256) + data[5] + local newFlags = data[6] + Protocol.elrsFlags = newFlags + Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) +end + +function Protocol.parseElrsV1Message(data) + if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then + return + end + Protocol.fieldPopup = { id = 0, status = Protocol.CRSF.CMD_EXECUTING, timeout = 0xFF, info = "ERROR: 1.x firmware" } + Protocol.fieldTimeout = getTime() + 0xFFFF +end + +-- ============================================================================ +-- Protocol: Main CRSF communication loop (renamed from refreshNext) +-- ============================================================================ + +function Protocol.poll() + local command, data + local deviceInfoResult = nil + + repeat + command, data = Protocol.pop() + if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then + deviceInfoResult = Protocol.parseDeviceInfoMessage(data) + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + Protocol.parseParameterInfoMessage(data) + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + elseif Protocol.fieldPopup then + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then + Protocol.parseElrsV1Message(data) + elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then + Protocol.parseElrsInfoMessage(data) + end + until command == nil + + -- Auto-discover other devices when link transitions to connected + local connected = Protocol.isConnected() + if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + Protocol.devicesRefreshTimeout = getTime() + 100 + end + Protocol.wasConnected = connected + + local time = getTime() + -- Flush any debounced saves whose timer has expired + for id, ps in pairs(Protocol.pendingSaves) do + if time > ps.timeout then + Protocol.pendingSaves[id] = nil + Protocol.fieldIntSave(ps.field) + Protocol.reloadParentFolder(ps.field) + end + end + + if Protocol.fieldPopup then + if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) + Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout + end + elseif time > Protocol.devicesRefreshTimeout and #Protocol.devices == 0 then + Protocol.devicesRefreshTimeout = time + 100 + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + elseif time > Protocol.linkstatTimeout then + if Protocol.deviceIsELRS_TX then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + else + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + end + Protocol.linkstatTimeout = time + 100 + elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then + if #Protocol.loadQueue > 0 then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) + Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() + else + Protocol.backgroundLoading = false + end + end + + return { deviceInfo = deviceInfoResult } +end + +-- ============================================================================ +-- UI Module: LVGL page/widget building +-- ============================================================================ + +UI = { + currentPage = nil, + uiBuilt = false, + folderWasReady = false, +} + +-- Called by Navigation/run loop to trigger UI rebuild +function UI.invalidate() + UI.uiBuilt = false +end + +function UI.getSubtitle() + -- When inside a folder, show current menu item name + if not Navigation.isAtRoot() then + local top = Navigation.stack[#Navigation.stack] + local path = top.name or "" + + -- Append loading indicator only when current folder's children aren't ready + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if loaded and loaded < total then + path = path .. string.format(" • Loading %d%%", math.floor(loaded / total * 100)) + end + + return path + end + + -- At root level, show loading status only during initial load (not background) + local loaded, total = Protocol.getFolderLoadProgress(nil) + if loaded and loaded < total and Protocol.fieldsCount > 0 then + return string.format("Loading %d%%", math.floor(loaded / total * 100)) + end + + -- At root level, show link stats (and warning if present) + local subtitle = "" + if Protocol.receivedPackets then + local state = Protocol.isConnected() and "Connected" or "No link" + subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + -- Add warning indicator if warning/error flags are set (bits 2+, above status bits 0-1) + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then + if subtitle ~= "" then + subtitle = subtitle .. " • " .. Protocol.elrsFlagsInfo + else + subtitle = Protocol.elrsFlagsInfo + end + end + + return subtitle +end + +function UI.incrField(field, step) + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until (newval == min or newval == max) +end + +-- Check if field has only Off/On values +function UI.isBooleanField(field) + if not field.values or #field.values ~= 2 then + return false + end + return field.values[1] == "Off" and field.values[2] == "On" +end + +-- ============================================================================ +-- UI: Widget creators +-- ============================================================================ + +-- Narrow screens (e.g. FlySky EL18 portrait, PA01) need more room for controls +local IS_NARROW = LCD_W < 400 +local LABEL_PCT = IS_NARROW and 42 or 50 +local CTRL_PCT = 100 - LABEL_PCT + +function UI.createChoiceRow(pg, field) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0 + }) + + local labelRect = row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + h = lvgl.UI_ELEMENT_HEIGHT, + thickness = 0, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + text = field.name or "", + color = COLOR_THEME_PRIMARY1 + } + } + }) + + local ctrlRect = row:rectangle({ + w = lvgl.PERCENT_SIZE + CTRL_PCT, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER + }) + + if UI.isBooleanField(field) then + ctrlRect:toggle({ + get = function() return field.value or 0 end, + set = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end + }) + else + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, v in ipairs(field.values or {}) do + if v ~= "" then + filteredValues[#filteredValues + 1] = v + origToFiltered[i - 1] = #filteredValues + filteredToOrig[#filteredValues] = i - 1 + end + end + ctrlRect:choice({ + values = filteredValues, + get = function() return origToFiltered[field.value or 0] or 1 end, + set = function(val) + field.value = filteredToOrig[val] or 0 + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end + }) + end + + if field.unit and field.unit ~= "" then + ctrlRect:box({ + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + text = " " .. field.unit, + } + } + }) + end +end + +function UI.createNumberRow(pg, field) + local displayFn + if field.type == Protocol.CRSF.FLOAT then + displayFn = function(val) + return string.format(field.fmt or "%.0f", val / (field.prec or 1)) + end + else + displayFn = function(val) + return tostring(val) .. (field.unit or "") + end + 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, thickness=0, children={ + {type="label", text=field.name or "", color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, thickness=0, flexFlow=lvgl.FLOW_ROW, align=LEFT, children={ + {type="numberEdit", min=field.min or 0, max=field.max or 255, + get=function() return field.value or 0 end, + set=function(val) + field.value = val + end, + edited=function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadParentFolder(field) + end, + display=displayFn, + active=function() return not field.disabled end}, + }}, + }}, + }) +end + +function UI.createInfoRow(pg, field) + 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, thickness=0, children={ + {type="label", text=field.name or "", color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, thickness=0, flexFlow=lvgl.FLOW_ROW, align=LEFT, children={ + {type="label", text=field.value or ""}, + }}, + }}, + }) +end + +function UI.createFolderWidget(pg, field, width) + pg:button({ + text = field.name or "", + w = width or (lvgl.PERCENT_SIZE + 100), + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + App.openFolder(field.id, field.name) + end + }) +end + +function UI.createCommandWidget(pg, field) + pg:button({ + text = field.name or "", + w = lvgl.PERCENT_SIZE + 100, + press = function() + Protocol.handleCommandSave(field) + end + }) +end + +function UI.buildFieldWidget(pg, field, folderWidth) + if not field or not field.name then + return + end + + local fieldType = field.type + + if fieldType == Protocol.CRSF.FOLDER then + return UI.createFolderWidget(pg, field, folderWidth) + end + + if fieldType == Protocol.CRSF.COMMAND then + return UI.createCommandWidget(pg, field) + end + + if fieldType <= Protocol.CRSF.INT16 or fieldType == Protocol.CRSF.FLOAT then + return UI.createNumberRow(pg, field) + end + + if fieldType == Protocol.CRSF.TEXT_SELECTION then + return UI.createChoiceRow(pg, field) + end + + if fieldType == Protocol.CRSF.STRING or fieldType == Protocol.CRSF.INFO then + return UI.createInfoRow(pg, field) + end +end + +-- ============================================================================ +-- UI: Main build function +-- ============================================================================ + +function UI.build() + lvgl.clear() + + local pageOptions = { + title = "ExpressLRS", + subtitle = UI.getSubtitle + } + + if not Navigation.isAtRoot() then + pageOptions.backButton = true + pageOptions.back = function() + App.handleBack() + end + else + pageOptions.back = App.handleBack + end + + UI.currentPage = lvgl.page(pageOptions) + + local outerContainer = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + align = CENTER + }) + + local fieldContainer = outerContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_TINY + }) + + local currentFolder = Navigation.getCurrent() + + -- Top spacer for visual breathing room (mirrors bottom spacer) + -- fieldContainer:rectangle({ w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_TINY, thickness = 0 }) + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + -- Render device list directly from Protocol.devices + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + fieldContainer:button({ + text = device.name or "Unknown", + w = lvgl.PERCENT_SIZE + 100, + press = function() + App.changeDevice(device.id) + end + }) + end + end + else + -- Normal field rendering for root (nil) or a subfolder ID + if currentFolder == nil then + -- Device name row at root level + UI.createInfoRow(fieldContainer, { name = "Device", value = Protocol.deviceName or "Searching..." }) + end + + local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) + -- Narrow screens (e.g. FlySky EL18 portrait, PA01) are too narrow for 3 folders per row + local FOLDERS_PER_ROW = IS_NARROW and 1 or 3 + local folderWidth = math.floor(100 / FOLDERS_PER_ROW) + local i = 1 + while i <= #fieldsInFolder do + local field = fieldsInFolder[i] + + if field.type == Protocol.CRSF.FOLDER then + -- Batch consecutive folders into rows + local folderBatch = {} + while i <= #fieldsInFolder and fieldsInFolder[i].type == Protocol.CRSF.FOLDER do + table.insert(folderBatch, fieldsInFolder[i]) + i = i + 1 + end + + if FOLDERS_PER_ROW == 1 then + -- Single column: add each folder directly, no row wrapper needed + for j = 1, #folderBatch do + UI.createFolderWidget(fieldContainer, folderBatch[j]) + end + else + for j = 1, #folderBatch, FOLDERS_PER_ROW do + local rowContainer = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + align = CENTER, + color = COLOR_THEME_PRIMARY2 + }) + + for k = 0, FOLDERS_PER_ROW - 1 do + local folderField = folderBatch[j + k] + if folderField then + UI.createFolderWidget(rowContainer, folderField, lvgl.PERCENT_SIZE + folderWidth) + end + end + end + end + else + UI.buildFieldWidget(fieldContainer, field) + i = i + 1 + end + end + + -- Synthetic Lua script version row at root when device is TX + if currentFolder == nil and Protocol.deviceIsELRS_TX then + UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) + end + + -- Append "Other Devices" button at the end when at root with multiple devices + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + fieldContainer:button({ + text = "Other Devices", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + App.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + end + }) + end + end + + fieldContainer:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0 + }) + + UI.uiBuilt = true +end + +-- ============================================================================ +-- Dialogs Module: Generic LVGL wrappers (no business logic) +-- ============================================================================ + +Dialogs = {} + +function Dialogs.showConfirm(options) + return lvgl.confirm({ + title = options.title, + message = options.message, + confirm = options.onConfirm, + cancel = options.onCancel, + }) +end + +function Dialogs.showMessage(options) + return lvgl.message({ + title = options.title, + message = options.message, + }) +end + +-- ============================================================================ +-- ModelMismatchDialog: Domain-specific dialog +-- ============================================================================ + +local ModelMismatchDialog = {} + +function ModelMismatchDialog.show(onContinue, onExit) + local dg = lvgl.dialog({ + title = "Model Mismatch", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL + }) + + dg:build({ + {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ + {type="label", text="Receiver connected but Model ID doesn't match."}, + {type="label", text="This prevents controlling the wrong model."}, + {type="label", text="To use this receiver:"}, + {type="label", text="Set Model Match to OFF"}, + }}, + {type="box", flexFlow=lvgl.FLOW_ROW, flexPad=lvgl.PAD_SMALL, w=lvgl.PERCENT_SIZE+100, children={ + {type="button", text="Continue", w=lvgl.PERCENT_SIZE+48, press=function() + dg:close() + onContinue() + end}, + {type="button", text="Exit to Change Model", w=lvgl.PERCENT_SIZE+48, press=function() + dg:close() + onExit() + end}, + }}, + }) + + return dg +end + +-- ============================================================================ +-- NoModuleDialog: Domain-specific dialog +-- ============================================================================ + +local NoModuleDialog = {} + +function NoModuleDialog.show(onExit) + lvgl.clear() + + local dg = lvgl.dialog({ + title = "No Module Found: Check Model Settings", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = onExit + }) + + dg:build({ + {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ + {type="label", text="- Internal/External module enabled"}, + {type="label", text="- Protocol set to CRSF"}, + {type="label", text="- Minimum Baud rate (depends on packet rate):"}, + {type="label", text=" 400k for 250Hz", font=SMLSIZE}, + {type="label", text=" 921k for 500Hz", font=SMLSIZE}, + {type="label", text=" 1.87M for F1000", font=SMLSIZE}, + }}, + {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() + onExit() + end}, + }}, + }) + + return dg +end + +-- ============================================================================ +-- CommandPage: Non-modal pages for command confirm/executing states. +-- Uses lvgl.page() instead of lvgl.dialog() so that run() keeps being +-- called and protocol polling continues during command execution. +-- ============================================================================ + +local CommandPage = {} +local spinnerAngle = 0 + +local function createSpinner(parent) + local r = 20 + local d = r * 2 + local wrapper = parent:box({ + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + color = COLOR_THEME_PRIMARY2, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER + }) + wrapper:arc({ + radius = r, + thickness = 4, + rounded = true, + color = COLOR_THEME_PRIMARY1, + startAngle = function() + spinnerAngle = (spinnerAngle + 8) % 360 + return spinnerAngle + end, + endAngle = function() + return spinnerAngle + 120 + end, + }) +end + +function CommandPage.showConfirm(name, info, onConfirm, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = "Send command", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + }) + + container:build({ + {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, + {type="label", text=name or "Command", w=lvgl.PERCENT_SIZE+100, align=CENTER, font=BOLD}, + {type="label", text=info or "", w=lvgl.PERCENT_SIZE+100, align=CENTER, color=COLOR_THEME_DISABLED}, + {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, + {type="box", w=lvgl.PERCENT_SIZE+100, flexFlow=lvgl.FLOW_ROW, flexPad=lvgl.PAD_SMALL, align=CENTER, children={ + {type="button", text="Confirm", w=lvgl.PERCENT_SIZE+49, press=onConfirm}, + {type="button", text="Cancel", w=lvgl.PERCENT_SIZE+49, press=onCancel}, + }}, + }) + + return pg +end + +function CommandPage.showExecuting(title, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = title or "Executing...", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + }) + + createSpinner(container) + + container:build({ + {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_SMALL, thickness=0}, + {type="label", text="Hold [RTN] to exit and keep running", w=lvgl.PERCENT_SIZE+100, align=CENTER, color=COLOR_THEME_DISABLED}, + {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, + {type="button", text="Cancel command", w=lvgl.PERCENT_SIZE+100, press=onCancel}, + }) + + return pg +end + +-- ============================================================================ +-- Mock data for simulator +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + Protocol.pop = mock.pop + Protocol.push = mock.push + Protocol.hasCrsfModule = function() return mock.moduleFound end +end + +-- ============================================================================ +-- Coordination: Command popup handling (in run loop) +-- ============================================================================ + +local function onCommandCancel() + Protocol.commandCancel() + App.commandDialog = nil + UI.invalidate() +end + +local function handleCommandPopup() + if not Protocol.fieldPopup then + -- Command finished: rebuild normal UI if we were showing a command page + if App.commandDialog then + App.commandDialog = nil + UI.invalidate() + end + return + end + + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + App.commandDialog = nil + UI.invalidate() + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if not App.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then + App.commandDialog = CommandPage.showConfirm( + Protocol.fieldPopup.name, + Protocol.fieldPopup.info, + function() Protocol.commandConfirm() end, + onCommandCancel + ) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if not App.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then + App.commandDialog = CommandPage.showExecuting( + Protocol.fieldPopup.name or Protocol.fieldPopup.info, + onCommandCancel + ) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + end +end + +-- ============================================================================ +-- Coordination: Warning handling (in run loop) +-- ============================================================================ + +local function handleWarning() + if App.shouldExit then + return + end + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if not App.warningDialog and not App.warningDismissed then + if Protocol.elrsFlagsInfo == "Model Mismatch" then + App.warningDialog = ModelMismatchDialog.show( + function() + App.warningDismissed = true + UI.invalidate() + end, + function() + App.warningDismissed = true + App.shouldExit = true + end + ) + else + Dialogs.showMessage({ + title = "Warning", + message = Protocol.elrsFlagsInfo + }) + App.warningDialog = true + end + end + else + App.warningDialog = nil + App.warningDismissed = false + end +end + +-- ============================================================================ +-- EdgeTX version check +-- ============================================================================ + +local versionCheckResult = nil + +-- Check EdgeTX version: requires 2.11.5+, 2.12-rc4+, or 3.0+ +-- Called once from init(); result stored in versionCheckResult. +-- +-- Version logic truth table: +-- 2.11.4 -> FAIL +-- 2.11.5 -> PASS +-- 2.11.6 -> PASS +-- 2.12.0-rc3 -> FAIL +-- 2.12.0-rc4 -> PASS +-- 2.12.0 -> PASS (release) +-- 2.12.1 -> PASS +-- 3.0.0 -> PASS +local function checkEdgeTxVersion() + local ver, radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor >= 13 then + return true + elseif maj == 2 and minor == 12 then + local rc = string.match(ver, "%-rc(%d+)") + if rc then + return tonumber(rc) >= 4 + end + return true -- release or dev build + elseif maj == 2 and minor == 11 and rev >= 5 then + return true + end + + return false +end + +local function showVersionRequired() + lvgl.clear() + + local dg = lvgl.dialog({ + title = "EdgeTX Version Not Supported", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() App.shouldExit = true end + }) + + dg:build({ + {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ + {type="label", text="Requires EdgeTX:"}, + {type="label", text="- 2.11.5 or later"}, + {type="label", text="- 2.12-rc4 or later"}, + {type="label", text="- 3.0 or later"}, + }}, + {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}, + }}, + }) +end + +-- ============================================================================ +-- Init +-- ============================================================================ + +local function init() + if lvgl == nil then + return + end + + if VERSION_CHECK_ENABLED then + versionCheckResult = checkEdgeTxVersion() + end + + setMock() +end + +-- ============================================================================ +-- LVGL support check +-- ============================================================================ + +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+, 2.12-rc4+, or 3.0+ needed", 0) +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 EdgeTX version (result computed once in init) + if versionCheckResult == false then + if not UI.uiBuilt then + showVersionRequired() + UI.uiBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + -- Check for CRSF module + if not App.checkCrsfModule() then + if not UI.uiBuilt then + NoModuleDialog.show(function() App.shouldExit = true end) + UI.uiBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + -- CRSF polling + local pollResult = Protocol.poll() + + -- Handle device info update + if pollResult.deviceInfo then + -- Change device if protocol indicates current device was updated + if pollResult.deviceInfo.shouldChangeDevice then + App.changeDevice(pollResult.deviceInfo.deviceId) + end + -- Refresh UI if a new device appeared (shows "Other Devices" button at root, + -- or updates the device list if already viewing that folder). + -- At root level, wait until the folder has finished loading before rebuilding. + if pollResult.deviceInfo.isNewDevice and UI.folderWasReady then + UI.invalidate() + elseif Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES then + UI.invalidate() + end + end + + -- Handle command popups + handleCommandPopup() + + -- When a command page is active, skip normal UI management + if not App.commandDialog then + -- Handle warnings + handleWarning() + + -- Rebuild UI once when the current folder's fields become fully loaded + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + -- Reclaim memory from temporary tables created during field parsing. + -- Done once per folder load rather than per-field to avoid repeated + -- full GC cycles on the hot path (fieldGetStrOrOpts). + collectgarbage("collect") + if UI.uiBuilt then + UI.invalidate() + end + -- Start background preloading of subfolder contents once root is ready + if currentFolder == nil and not Protocol.backgroundLoading then + Protocol.startBackgroundLoad() + end + end + UI.folderWasReady = folderReady + + -- Build/rebuild UI when needed + if not UI.uiBuilt and #Protocol.fields > 0 then + UI.build() + end + end + + if App.shouldExit then + return 2 + end + + return 0 +end + +-- ============================================================================ +-- Return +-- ============================================================================ + +return { init = init, run = run, useLvgl = true } diff --git a/color/WIDGETS/ELRSTelemetry/loadable.lua b/color/WIDGETS/ELRSTelemetry/loadable.lua new file mode 100644 index 0000000..8223877 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/loadable.lua @@ -0,0 +1,556 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Core Logic -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua -- +-- -- +-- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- +-- singleton passed from main.lua for device info discovery. -- +-- -- +-- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- +--------------------------------------------------------------------------- + +local zone, options, crsf = ... + +-- Forward declarations for modules +local Telemetry + +-- ============================================================================ +-- Telemetry Module: cell counting, state, power mapping +-- ============================================================================ + +Telemetry = { + -- Smoothed range percentage + smoothRng = nil, + + -- Cell count detection state + cellCnt = nil, + cellCntCnt = 0, + cellLastV = nil, + + -- Diversity detection + isDiversity = false, + + -- Cached GPS position (persists across disconnects) + gps = nil, + + -- Power level mapping table + POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 }, +} + +--- Map a power value in mW to a 0-based index. +function Telemetry.pwrToIdx(powval) + for k, v in ipairs(Telemetry.POWERS) do + if powval == v then + return k - 1 + end + end + return 7 +end + +--- Cell count detection heuristic (same logic as original). +function Telemetry.checkCellCount(v) + if (Telemetry.cellCntCnt or 0) > 5 then + return + end + + local cellCnt = math.floor(v / 4.35) + 1 + if (v / cellCnt) < 3.0 then + return + end + + if Telemetry.cellCnt ~= cellCnt then + Telemetry.cellCnt = cellCnt + Telemetry.cellCntCnt = 0 + else + if Telemetry.cellLastV == v then + return + end + Telemetry.cellLastV = v + Telemetry.cellCntCnt = Telemetry.cellCntCnt + 1 + end +end + +--- Read all link telemetry values into a table. +function Telemetry.readLink() + return { + tpwr = crsf.getSensorValue("TPWR"), + rfmd = crsf.getSensorValue("RFMD"), + rssi1 = crsf.getSensorValue("1RSS"), + rssi2 = crsf.getSensorValue("2RSS"), + rqly = crsf.getSensorValue("RQly"), + ant = crsf.getSensorValue("ANT"), + } +end + +--- Check if a CRSF/ELRS module is available. +function Telemetry.hasModule() + return crsf.hasCrsfModule() +end + + +--- Short status text when not operational or warning active. +--- Returns nil when connected with no warnings. +--- Used by both full-screen and minimized UIs. +function Telemetry.statusText() + if not crsf.hasCrsfModule() then + return "No CRSF module" + end + if not crsf.rxConnected then + return "No RX" + end + if crsf.modelMismatch then + return "Model Mismatch" + end + return nil +end + +--- Compute smoothed range percentage from RSSI. +function Telemetry.getRangePct(tlm) + local mod = crsf.deviceInfo + local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 + if rssi == nil then + return 0 + end + local minrssi = (mod.RFRSSI and mod.RFRSSI[(tlm.rfmd or 0) + 1]) or -128 + if rssi > -50 then + rssi = -50 + end + local pct = math.floor(100 * (rssi + 50) / (minrssi + 50) + 0.5) + local smooth = Telemetry.smoothRng or pct + if pct > smooth then + pct = smooth + ((pct > smooth + 8) and 4 or 1) + elseif pct < smooth then + pct = smooth - ((pct < smooth - 8) and 4 or 1) + end + Telemetry.smoothRng = pct + return pct +end + +--- Get RF mode string from device info. +function Telemetry.getRfModeStr(rfmd) + local mod = crsf.deviceInfo + return (mod.RFMOD and mod.RFMOD[(rfmd or 0) + 1]) or table.concat({"RFMD", tostring(rfmd or 0)}) +end + +--- Update GPS cache from telemetry. +function Telemetry.updateGps() + local gps = crsf.getSensorValue("GPS") + if gps and gps ~= 0 then + Telemetry.gps = gps + end +end + +--- Update diversity flag from ant value. +function Telemetry.updateDiversity(ant) + if ant and ant ~= 0 then + Telemetry.isDiversity = true + end +end + +--- Pick the active antenna's RSSI value from a readLink() result. +function Telemetry.getRssi(tlm) + if not tlm then return nil end + return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 +end + +--- Map range percentage to a warning color. +function Telemetry.rangeColor(pct) + if pct > 90 then return RED end + if pct > 70 then return ORANGE end + return COLOR_THEME_SECONDARY1 +end + +--- Range percentage + RSSI text (e.g. "Range 69% -90dBm"). +function Telemetry.signalText() + if not crsf.rxConnected then + return "" + end + local tlm = Telemetry.readLink() + local pct = Telemetry.getRangePct(tlm) + local parts = { table.concat({"Range ", tostring(pct), "%"}) } + local rssi = Telemetry.getRssi(tlm) + if rssi then + parts[#parts + 1] = table.concat({tostring(rssi), "dBm"}) + end + return table.concat(parts, " ") +end + +--- RF mode + TX power text (e.g. "250Hz 50mW"). +function Telemetry.rfDetailText() + local tlm = Telemetry.readLink() + local mode = Telemetry.getRfModeStr(tlm.rfmd) + local parts = { mode } + if crsf.rxConnected and tlm.tpwr then + parts[#parts + 1] = table.concat({tostring(tlm.tpwr), "mW"}) + end + return table.concat(parts, " ") +end + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, + color = COLOR_THEME_PRIMARY2, opacity = opa }, + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = LEFT, + children = children }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, + color = COLOR_THEME_PRIMARY2, opacity = opa }, + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, + children = children }, + }) +end + +-- ============================================================================ +-- Screen detection and UI loading +-- ============================================================================ + +--- Detect screen resolution and return an ID for the per-screen UI file. +local function getScreenId() + local w, h = LCD_W, LCD_H + if w >= 800 then + return "hd" -- 800x480 + elseif w < h then + return "portrait" -- 320x480 (EL18) + elseif w <= 320 then + return "small" -- 320x240 + elseif h >= 320 then + return "sd_tall" -- 480x320 (TX16S) + else + return "sd" -- 480x272 + end +end + +--- Convert Transparency option (0-5) to LVGL opacity (255-0). +local function bgOpacity(opts) + local t = (opts and opts.Transparency) or 2 + return math.max(0, 255 - 51 * t) +end + +local screenId = getScreenId() +local uiPath = table.concat({"/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua"}) +local WidgetUI = loadScript(uiPath)({ + crsf = crsf, + Telemetry = Telemetry, + bgOpacity = bgOpacity, + WidgetLayout = WidgetLayout, +}) + +-- ============================================================================ +-- Full-screen row helpers (shared across all screen sizes) +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for values. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +local function createDisplayRow(container, label, valueFn, colorFn) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + children = { + { type = lvgl.LABEL, text = label, color = COLOR_THEME_PRIMARY1, + w = lvgl.PERCENT_SIZE + LABEL_PCT, y = lvgl.PAD_SMALL }, + { type = lvgl.LABEL, text = valueFn, color = colorFn or COLOR_THEME_SECONDARY1, + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), y = lvgl.PAD_SMALL }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { type = "rectangle", w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0 }, + { type = "label", text = title, font = BOLD, color = COLOR_THEME_PRIMARY1 }, + }) +end + +-- ============================================================================ +-- Full-screen LVGL layout (shared across all screen sizes) +-- ============================================================================ + +local function buildFullScreen() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = function() + if not Telemetry.hasModule() then + return "No CRSF module" + end + if not crsf.rxConnected then + return "No RX Connected" + end + if crsf.modelMismatch then + return "Model Mismatch" + end + return "Telemetry" + end, + back = function() lvgl.exitFullScreen() end, + }) + + -- No module — show checklist instead of telemetry (matches expresslrs.lua NoModuleDialog) + if not Telemetry.hasModule() then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", color = COLOR_THEME_DISABLED }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- Model mismatch warning banner + fields:build({ + { type = "label", text = "Model Mismatch — RC commands not sent", + font = BOLD, color = RED, + visible = function() return crsf.modelMismatch end }, + }) + + -- Link Status section + createSectionHeader(fields, "Link Status") + + createDisplayRow(fields, "RF Mode", + function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end) + + createDisplayRow(fields, "Link Quality", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + return table.concat({tostring(tlm.rqly or 0), "%"}) + end) + + createDisplayRow(fields, "RSSI 1", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi1 == nil then + return "--" + end + return table.concat({tostring(tlm.rssi1), " dBm"}) + end) + + createDisplayRow(fields, "RSSI 2", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi2 == nil then + return "--" + end + return table.concat({tostring(tlm.rssi2), " dBm"}) + end, + function() + if not Telemetry.isDiversity then + return COLOR_THEME_DISABLED + end + return COLOR_THEME_SECONDARY1 + end) + + createDisplayRow(fields, "Active Antenna", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if not Telemetry.isDiversity then + return "N/A" + end + return (tlm.ant == 1) and "2" or "1" + end) + + createDisplayRow(fields, "Range", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + local pct = Telemetry.getRangePct(tlm) + return table.concat({tostring(pct), "%"}) + end) + + -- Power section + createSectionHeader(fields, "Power") + + createDisplayRow(fields, "TX Power", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return table.concat({tostring(tlm.tpwr), " mW"}) + end) + + createDisplayRow(fields, "Power Index", + function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return tostring(Telemetry.pwrToIdx(tlm.tpwr)) + end) + + -- Flight Controller section + createSectionHeader(fields, "Flight Controller") + + createDisplayRow(fields, "Battery", + function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "--" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) + end + return string.format("%.2fV", vbat) + end) + + createDisplayRow(fields, "Current", + function() + local curr = crsf.getSensorValue("Curr") + if curr == nil or curr <= 0 then + return "--" + end + return string.format("%.2f A", curr) + end) + + createDisplayRow(fields, "Flight Mode", + function() + local fm = crsf.getSensorValue("FM") + if fm == nil or fm == 0 then + return "--" + end + return tostring(fm) + end) + + -- GPS section + createSectionHeader(fields, "GPS") + + createDisplayRow(fields, "Satellites", + function() + local sats = crsf.getSensorValue("Sats") + if sats == nil then + return "--" + end + return tostring(sats) + end) + + createDisplayRow(fields, "Speed", + function() + local gspd = crsf.getSensorValue("GSpd") + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) + + createDisplayRow(fields, "Altitude", + function() + local alt = crsf.getSensorValue("Alt") + if alt == nil then + return "--" + end + return tostring(alt) + end) + + createDisplayRow(fields, "Latitude", + function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lat) + end) + + createDisplayRow(fields, "Longitude", + function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lon) + end) +end + +-- ============================================================================ +-- Widget lifecycle +-- ============================================================================ + +local wgt = { + zone = zone, + options = options, + wasFullScreen = false, +} + +function wgt.background() + crsf:poll() + crsf:requestDeviceInfo() + crsf:requestElrsStatus() + Telemetry.updateGps() +end + +function wgt.refresh(event, touchState) + wgt.background() + + -- Update diversity detection each tick + if crsf.rxConnected then + local tlm = Telemetry.readLink() + Telemetry.updateDiversity(tlm.ant) + end + + local isFullScreen = lvgl.isFullScreen() + if isFullScreen ~= wgt.wasFullScreen then + wgt.wasFullScreen = isFullScreen + if isFullScreen then + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end + end +end + +function wgt.update(newOptions) + wgt.options = newOptions + WidgetUI.build(wgt.zone, wgt.options) +end + +-- Initial build +WidgetUI.build(wgt.zone, wgt.options) + +return wgt diff --git a/color/WIDGETS/ELRSTelemetry/main.lua b/color/WIDGETS/ELRSTelemetry/main.lua new file mode 100644 index 0000000..7f54527 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/main.lua @@ -0,0 +1,43 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget -- +-- Displays ELRS link telemetry: RSSI, LQ, Range, RF Mode, Power, -- +-- Battery, Current, GPS, and Flight Mode. -- +-- -- +-- Uses the loadable.lua pattern to minimize memory when not in use. -- +-- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +--------------------------------------------------------------------------- + +local name = "ELRSTelemetry" + +local function create(zone, options) + if not _crsfSingleton then + local getCRSF = loadScript("/SCRIPTS/ELRSLib/crsf.lua") + _crsfSingleton = getCRSF() + end + local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + return loadable(zone, options, _crsfSingleton) +end + +local function refresh(widget, event, touchState) + widget.refresh(event, touchState) +end + +local function background(widget) + widget.background() +end + +local function update(widget, options) + widget.update(options) +end + +return { + name = "ExpressLRS Telemetry", + create = create, + refresh = refresh, + background = background, + update = update, + options = { + { "Transparency", VALUE, 2, 0, 5 }, + }, + useLvgl = true, +} diff --git a/color/WIDGETS/ELRSTelemetry/ui/hd.lua b/color/WIDGETS/ELRSTelemetry/ui/hd.lua new file mode 100644 index 0000000..b047f50 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -0,0 +1,191 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 800x480 (HD) -- +-- High definition landscape (TX16S Mark 3) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 800x480. +WidgetUI.breakpoints = { + topBarW = 200, + tinyH = 74, + smallH = 104, + thirdH = 146, +} + +WidgetUI.fonts = { + tiny = { hero = BOLD }, + small = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + normal = { hero = DBLSIZE, detail = 0 }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.rxConnected then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, Telemetry = Telemetry, +}) + +--- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildTiny(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = BOLD, + color = heroColorMismatch, + text = heroTextLq }, + }}, + { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildSmall(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "label", w = c1w, font = BOLD, align = LEFT, + color = heroColorMismatch, + text = heroTextLq }, + { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +--- HD has plenty of room for a title row. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + } + rows[#rows + 1] = { + type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- Normal: full telemetry display with title and large fonts. +function WidgetUI.buildNormal(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, + { type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return 0 + end + return DBLSIZE + end, + text = heroTextLq }, + { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText }, + { type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) + elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildNormal(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua new file mode 100644 index 0000000..af0a78b --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -0,0 +1,180 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x480 portrait. +WidgetUI.breakpoints = { + topBarW = 80, + tinyH = 55, + smallH = 78, + thirdH = 110, +} + +WidgetUI.fonts = { + tiny = { hero = BOLD }, + small = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + normal = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.rxConnected then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, Telemetry = Telemetry, +}) + +--- Tiny: single line — LQ (bold) + Range/dBm (colored). +--- Portrait is narrower so skip RF mode/power. +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildTiny(w, h, opa) + local c1w = math.floor(w * 0.35) + local c2w = w - c1w + local columns = { + { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = BOLD, + color = heroColorMismatch, + text = heroTextLq }, + }}, + { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, + text = Telemetry.signalText }, + }}, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildSmall(w, h, opa) + local c1w = math.floor(w * 0.35) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "label", w = c1w, font = BOLD, align = LEFT, + color = heroColorMismatch, + text = heroTextLq }, + { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- Normal: full telemetry display with title. +function WidgetUI.buildNormal(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, + { type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq }, + { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText }, + { type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) + elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildNormal(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd.lua b/color/WIDGETS/ELRSTelemetry/ui/sd.lua new file mode 100644 index 0000000..ed86eb9 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -0,0 +1,181 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x272 (SD) -- +-- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + tinyH = 37, + smallH = 52, + thirdH = 73, +} + +WidgetUI.fonts = { + tiny = { hero = BOLD }, + small = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + normal = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.rxConnected then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, Telemetry = Telemetry, +}) + +--- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildTiny(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = BOLD, + color = heroColorMismatch, + text = heroTextLq }, + }}, + { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildSmall(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "label", w = c1w, font = BOLD, align = LEFT, + color = heroColorMismatch, + text = heroTextLq }, + { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: hero LQ + Range/RSSI detail. No title on SD. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- No title row on 480x272 — too tight + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- Normal: full telemetry display with title. +function WidgetUI.buildNormal(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, + { type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq }, + { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText }, + { type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) + elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildNormal(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua new file mode 100644 index 0000000..c6f06fb --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -0,0 +1,192 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x320 (SD Tall) -- +-- TX16S, TX16S MAX, TX16S Mark II -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x320. +-- 48px taller than 480x272 so widget zones are proportionally taller. +WidgetUI.breakpoints = { + topBarW = 100, + tinyH = 44, + smallH = 62, + thirdH = 86, +} + +WidgetUI.fonts = { + tiny = { hero = BOLD }, + small = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + normal = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.rxConnected then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, Telemetry = Telemetry, +}) + +--- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildTiny(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = BOLD, + color = heroColorMismatch, + text = heroTextLq }, + }}, + { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildSmall(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "label", w = c1w, font = BOLD, align = LEFT, + color = heroColorMismatch, + text = heroTextLq }, + { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +--- 480x320 has enough room for a title row at 1/3. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row — 480x320 has more vertical room + rows[#rows + 1] = { + type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + } + rows[#rows + 1] = { + type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- Normal: full telemetry display with title. +function WidgetUI.buildNormal(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, + { type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq }, + { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText }, + { type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) + elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildNormal(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSTelemetry/ui/small.lua b/color/WIDGETS/ELRSTelemetry/ui/small.lua new file mode 100644 index 0000000..a15f114 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/small.lua @@ -0,0 +1,185 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x240 (Small) -- +-- Small color LCD (PA01) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x240. +-- Smallest color screen — everything is compact. +WidgetUI.breakpoints = { + topBarW = 80, + tinyH = 30, + smallH = 42, + thirdH = 58, +} + +WidgetUI.fonts = { + tiny = { hero = BOLD }, + small = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + normal = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.rxConnected then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, Telemetry = Telemetry, +}) + +--- Tiny: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildTiny(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = BOLD, + color = heroColorMismatch, + text = heroTextLq }, + }}, + { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { + { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, + text = function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end }, + }}, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildSmall(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "label", w = c1w, font = BOLD, align = LEFT, + color = heroColorMismatch, + text = heroTextLq }, + { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, + text = Telemetry.signalText }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + }}, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: hero LQ + Range/RSSI detail + RF mode. No title on small screen. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- No title row — too tight on 320x240 + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- Normal: full telemetry display with title. +function WidgetUI.buildNormal(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, + { type = "label", align = LEFT, + color = heroColorMismatch, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + text = heroTextLq }, + { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = detailColor, + text = Telemetry.signalText }, + { type = "label", font = SMLSIZE, align = LEFT, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText }, + { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) + elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildNormal(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSTelemetry/ui/topbar.lua b/color/WIDGETS/ELRSTelemetry/ui/topbar.lua new file mode 100644 index 0000000..72852a5 --- /dev/null +++ b/color/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -0,0 +1,66 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Shared Top Bar UI -- +-- Used by all screen-specific UI files for the top bar layout. -- +--------------------------------------------------------------------------- + +local ctx = ... +local crsf = ctx.crsf +local Telemetry = ctx.Telemetry + +local TopBarUI = {} + +local function getRssi(tlm) + if not tlm then + return nil + end + return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 +end + +--- Top bar: two lines stacked, no background. +function TopBarUI.build(w, h) + lvgl.build({ + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = CENTER, children = { + { type = "label", font = SMLSIZE, align = CENTER, + color = function() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY2 + end, + text = function() + if crsf.modelMismatch then + return "Model" + end + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + end }, + { type = "label", font = SMLSIZE, align = CENTER, + color = function() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY2 + end, + text = function() + if crsf.modelMismatch then + return "Mismatch" + end + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + local rssi = getRssi(tlm) + if rssi == nil then + return "" + end + return table.concat({tostring(rssi), "dBm"}) + end }, + }}, + }) +end + +return TopBarUI diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/color/WIDGETS/ELRSVTXAdmin/loadable.lua new file mode 100644 index 0000000..59aead8 --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -0,0 +1,1036 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - Core Logic -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua -- +-- -- +-- Communicates with the ELRS TX module's VTX Administrator via the -- +-- CRSF config protocol (PARAMETER_READ/WRITE). Field IDs are -- +-- discovered at runtime by name -- never hardcoded. -- +-- -- +-- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H. -- +--------------------------------------------------------------------------- + +local zone, options, crsf = ... + +-- Forward declarations for modules (needed for cross-references) +local VTX +local Protocol +local Presets + +-- ============================================================================ +-- VTX Module: Band lookups, field IDs, current/desired state, folder parsing +-- ============================================================================ + +VTX = { + -- Band name lookup tables + BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, + BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, + + -- Field IDs (discovered at runtime) + ids = { + folder = nil, + band = nil, + channel = nil, + power = nil, + pitmode = nil, + send = nil, + }, + + -- Current VTX state (parsed from folder name) + state = { + band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L + bandLetter = "?", + channel = 0, + power = 0, + pitmode = false, + }, + + -- Desired VTX state (edited by user in full-screen UI) + desired = { + band = 5, -- Raceband + channel = 1, + power = 0, + pitmode = 0, + }, +} + +--- Parse "VTX Admin (R:4:2:P)" into VTX.state fields. +-- When band is Off, folder name has no dynamic suffix. +function VTX.parseFolderName(name) + local s = VTX.state + local content = string.match(name, "%((.+)%)") + if not content then + s.band = 0; s.bandLetter = "Off"; s.channel = 0; s.power = 0; s.pitmode = false + return true + end + + local parts = {} + for part in string.gmatch(content, "([^:]+)") do + parts[#parts + 1] = part + end + if #parts < 2 then + return false + end + + s.bandLetter = parts[1] + s.band = VTX.BAND_VALUES[parts[1]] or 0 + s.channel = tonumber(parts[2]) or 0 + s.power = tonumber(parts[3]) or 0 + s.pitmode = (parts[#parts] == "P") + return true +end + +--- Sync desired values with current state (e.g. on discovery or entering full-screen). +function VTX.syncDesiredFromState() + local s = VTX.state + local d = VTX.desired + d.band = s.band + d.channel = s.channel + d.power = s.power + d.pitmode = s.pitmode and 1 or 0 +end + +-- ============================================================================ +-- Protocol Module: CRSF config protocol, state machine, discovery, write queue +-- ============================================================================ + +Protocol = { + -- State machine constants + STATE_INIT = 0, + STATE_NO_MODULE = 1, + STATE_DISCOVER_ROOT = 2, + STATE_DISCOVER_CHILDREN = 3, + STATE_DISCOVER_VTX = 4, + STATE_READY = 5, + STATE_SENDING = 6, + + -- Current state + state = 0, -- STATE_INIT + statusText = "Initializing...", + + -- Discovery + loadQueue = {}, + rootChildren = {}, + vtxChildren = {}, + fieldTimeout = 0, + discoveredFields = {}, + + -- Write queue + writeQueue = {}, + writeIdx = 0, + lastWriteTime = 0, + + -- Folder re-read timer + lastFolderPoll = 0, + FOLDER_POLL_INTERVAL = 200, -- 2 seconds +} + +-- State query helpers +function Protocol.isReady() return Protocol.state == Protocol.STATE_READY end +function Protocol.isSending() return Protocol.state == Protocol.STATE_SENDING end +function Protocol.isActive() return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING end + +-- Response timeout for PARAMETER_READ: 0.5s for local TX module. +function Protocol.fieldResponseTimeout() + return 50 +end + +-- ============================================================================ +-- Protocol: Parse helpers +-- ============================================================================ + +--- Parse child IDs from a FOLDER-type PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseChildIds(data) + local ids = {} + local off = 7 + while data[off] ~= nil and data[off] ~= 0 do + off = off + 1 + end + off = off + 1 -- skip null terminator + while data[off] ~= nil and data[off] ~= crsf.CONST.FIELD_LIST_END do + ids[#ids + 1] = data[off] + off = off + 1 + end + return ids +end + +--- Parse field name from a PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseFieldName(data) + local off = 7 + local startOff = off + while data[off] ~= nil and data[off] ~= 0 do + off = off + 1 + end + local chars = {} + for i = startOff, off - 1 do + chars[#chars + 1] = string.char(data[i]) + end + return table.concat(chars) +end + +--- Parse field type from a PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseFieldType(data) + return bit32.band(data[6] or 0, 0x7F) +end + +-- ============================================================================ +-- Protocol: Sending +-- ============================================================================ + +function Protocol.sendParameterRead(fieldId) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, 0 }) +end + +function Protocol.sendParameterWrite(fieldId, value) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, value }) +end + +--- Request ELRS_STATUS (connection state) by writing field 0. +function Protocol.sendStatusPoll() + Protocol.sendParameterWrite(0, 0) +end + +-- ============================================================================ +-- Protocol: Response handler (registered on singleton dispatcher) +-- ============================================================================ + +function Protocol.onSettingsEntry(data) + if data[2] ~= crsf.CONST.ADDRESS_TX_MODULE then + return + end + + local fieldId = data[3] + local fieldType = Protocol.parseFieldType(data) + local fieldName = Protocol.parseFieldName(data) + + Protocol.discoveredFields[fieldId] = { name = fieldName, type = fieldType } + + -- Pop the field from load queue if it matches + if #Protocol.loadQueue > 0 and Protocol.loadQueue[#Protocol.loadQueue] == fieldId then + Protocol.loadQueue[#Protocol.loadQueue] = nil + Protocol.fieldTimeout = 0 + end + + local st = Protocol.state + + if st == Protocol.STATE_DISCOVER_ROOT then + if fieldId == 0 and fieldType == crsf.CONST.FIELD_FOLDER then + Protocol.rootChildren = Protocol.parseChildIds(data) + for i = #Protocol.rootChildren, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.rootChildren[i] + end + Protocol.state = Protocol.STATE_DISCOVER_CHILDREN + Protocol.statusText = "Discovering fields..." + end + + elseif st == Protocol.STATE_DISCOVER_CHILDREN then + if fieldType == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then + VTX.ids.folder = fieldId + Protocol.vtxChildren = Protocol.parseChildIds(data) + VTX.parseFolderName(fieldName) + for i = #Protocol.vtxChildren, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.vtxChildren[i] + end + Protocol.state = Protocol.STATE_DISCOVER_VTX + Protocol.statusText = "Loading VTX fields..." + end + if #Protocol.loadQueue == 0 and VTX.ids.folder == nil then + Protocol.statusText = "VTX Admin not found" + end + + elseif st == Protocol.STATE_DISCOVER_VTX then + if fieldName == "Band" then VTX.ids.band = fieldId + elseif fieldName == "Channel" then VTX.ids.channel = fieldId + elseif fieldName == "Pwr Lvl" then VTX.ids.power = fieldId + elseif fieldName == "Pitmode" then VTX.ids.pitmode = fieldId + elseif fieldName == "Send VTx" then VTX.ids.send = fieldId + end + + if #Protocol.loadQueue == 0 then + if VTX.ids.band and VTX.ids.channel and VTX.ids.power and VTX.ids.pitmode and VTX.ids.send then + Protocol.state = Protocol.STATE_READY + Protocol.statusText = "" + VTX.syncDesiredFromState() + else + Protocol.statusText = "VTX fields incomplete" + end + end + + elseif st == Protocol.STATE_READY then + if fieldId == VTX.ids.folder then + VTX.parseFolderName(fieldName) + end + end +end + +-- Register on the shared CRSF singleton +crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, Protocol.onSettingsEntry) + +-- ============================================================================ +-- Protocol: State machine tick +-- ============================================================================ + +function Protocol.tick() + local now = getTime() + local st = Protocol.state + + if st == Protocol.STATE_INIT then + if crsf.hasCrsfModule() then + Protocol.state = Protocol.STATE_DISCOVER_ROOT + Protocol.statusText = "Discovering..." + Protocol.loadQueue = { 0 } + Protocol.fieldTimeout = 0 + else + Protocol.state = Protocol.STATE_NO_MODULE + Protocol.statusText = "No CRSF module" + end + + elseif st == Protocol.STATE_DISCOVER_ROOT + or st == Protocol.STATE_DISCOVER_CHILDREN + or st == Protocol.STATE_DISCOVER_VTX then + if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then + local fieldId = Protocol.loadQueue[#Protocol.loadQueue] + Protocol.sendParameterRead(fieldId) + Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() + end + + elseif st == Protocol.STATE_READY then + if now - Protocol.lastFolderPoll >= Protocol.FOLDER_POLL_INTERVAL then + Protocol.lastFolderPoll = now + Protocol.sendParameterRead(VTX.ids.folder) + Protocol.sendStatusPoll() + end + + elseif st == Protocol.STATE_SENDING then + if Protocol.writeIdx <= #Protocol.writeQueue then + if now - Protocol.lastWriteTime >= 5 then -- 50ms + local entry = Protocol.writeQueue[Protocol.writeIdx] + print(table.concat({"VTXAdmin: writing field=", entry[1], " val=", entry[2]})) + Protocol.sendParameterWrite(entry[1], entry[2]) + Protocol.lastWriteTime = now + Protocol.writeIdx = Protocol.writeIdx + 1 + end + else + print(table.concat({"VTXAdmin: write queue complete, ", #Protocol.writeQueue, " entries sent"})) + Protocol.writeQueue = {} + Protocol.writeIdx = 0 + Protocol.state = Protocol.STATE_READY + Protocol.lastFolderPoll = now - Protocol.FOLDER_POLL_INTERVAL + 10 + end + end +end + +-- ============================================================================ +-- Protocol: Write queue builder +-- ============================================================================ + +--- Write changed config fields (band, channel, power, pitmode) to the ELRS module. +--- Does NOT send the "Send VTx" command — call pushToVtx() separately for that. +function Protocol.writeConfig() + if not Protocol.isReady() then + print("VTXAdmin: writeConfig() skipped - not ready") + return + end + + local s = VTX.state + local d = VTX.desired + Protocol.writeQueue = {} + + print(table.concat({"VTXAdmin: writeConfig() desired: band=", d.band, " ch=", d.channel, + " pwr=", d.power, " pit=", tostring(d.pitmode)})) + print(table.concat({"VTXAdmin: writeConfig() current: band=", s.band, " ch=", s.channel, + " pwr=", s.power, " pit=", tostring(s.pitmode)})) + + if d.band ~= s.band then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.band, d.band } + end + if d.channel ~= s.channel then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.channel, d.channel } + end + if d.power ~= s.power then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.power, d.power } + end + + local desiredPit = d.pitmode + if type(desiredPit) == "boolean" then + desiredPit = desiredPit and 1 or 0 + end + local currentPit = s.pitmode and 1 or 0 + if desiredPit ~= currentPit then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.pitmode, desiredPit } + end + + print(table.concat({"VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)"})) + + if #Protocol.writeQueue > 0 then + Protocol.writeIdx = 1 + Protocol.lastWriteTime = 0 + Protocol.state = Protocol.STATE_SENDING + end +end + +--- Append the "Send VTx" command to the write queue, pushing config to the VTX. +function Protocol.pushToVtx() + if not Protocol.isReady() and Protocol.state ~= Protocol.STATE_SENDING then + print("VTXAdmin: pushToVtx() skipped - not ready") + return + end + + print("VTXAdmin: pushToVtx() - queuing Send VTx command") + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.send, crsf.CONST.CMD_CLICK } + + if Protocol.state ~= Protocol.STATE_SENDING then + Protocol.writeIdx = 1 + Protocol.lastWriteTime = 0 + Protocol.state = Protocol.STATE_SENDING + end +end + +-- ============================================================================ +-- Presets Module: 6POS preset storage, file I/O, quick-change processing +-- ============================================================================ + +Presets = { + PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt", + + -- Preset data + items = {}, + enabled = false, + source = 0, -- 6POS source ID (0 = not configured) + autoPushVtx = false, -- auto push to VTX on 6POS change + pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) + + -- 6POS processing state + lastPos = -1, + stablePos = -1, + stableTime = 0, + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- Push source edge detection state + pushLastVal = -1, +} + +-- ============================================================================ +-- Presets: File I/O (key=value format) +-- ============================================================================ + +--- Parse a "key=value" line using plain string.find (no regex). +--- Returns key, value strings or nil if no '=' found. +local function parseKV(line) + local eq = string.find(line, "=", 1, true) + if not eq then + return nil, nil + end + return string.sub(line, 1, eq - 1), string.sub(line, eq + 1) +end + +--- Split "band,channel" using plain string.find (no regex). +local function splitBandChannel(val) + local comma = string.find(val, ",", 1, true) + if not comma then + return nil, nil + end + return tonumber(string.sub(val, 1, comma - 1)), + tonumber(string.sub(val, comma + 1)) +end + +--- File format: key=value lines (one per line). +--- Keys: enabled, source, autoPushVtx, pushSource, p1..p6 (values: "band,channel"). +function Presets.load() + local p = {} + local enabled = false + local source = 0 + local autoPushVtx = false + local pushSource = 0 + local f = io.open(Presets.PATH, "r") + if f then + local data = io.read(f, 512) + io.close(f) + if data and #data > 0 then + -- Split by newlines using plain string.find + local pos = 1 + while pos <= #data do + local nl = string.find(data, "\n", pos, true) + local line + if nl then + line = string.sub(data, pos, nl - 1) + pos = nl + 1 + else + line = string.sub(data, pos) + pos = #data + 1 + end + local key, val = parseKV(line) + if key == "enabled" then + enabled = (val == "1") + elseif key == "source" then + source = tonumber(val) or 0 + elseif key == "autoPushVtx" then + autoPushVtx = (val == "1") + elseif key == "pushSource" then + pushSource = tonumber(val) or 0 + elseif key and val then + -- Check for p1..p6 using plain sub + if string.sub(key, 1, 1) == "p" then + local idx = tonumber(string.sub(key, 2)) + if idx and idx >= 1 and idx <= 6 then + local b, ch = splitBandChannel(val) + if b and ch then + p[idx] = { band = b, channel = ch } + end + end + end + end + end + end + end + -- Fill missing positions with Raceband defaults (R1..R6) + for i = 1, 6 do + if not p[i] then + p[i] = { band = 5, channel = i } + end + end + Presets.items = p + Presets.enabled = enabled + Presets.source = source + Presets.autoPushVtx = autoPushVtx + Presets.pushSource = pushSource + + print(table.concat({"VTXAdmin: presets loaded - enabled=", tostring(enabled), + " source=", source, " autoPushVtx=", tostring(autoPushVtx), " pushSource=", pushSource})) + for i = 1, 6 do + print(table.concat({"VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel})) + end +end + +function Presets.save() + print(table.concat({"VTXAdmin: saving presets to ", Presets.PATH})) + local f = io.open(Presets.PATH, "w") + if f then + io.write(f, table.concat({"enabled=", Presets.enabled and "1" or "0", "\n"})) + io.write(f, table.concat({"source=", Presets.source, "\n"})) + io.write(f, table.concat({"autoPushVtx=", Presets.autoPushVtx and "1" or "0", "\n"})) + io.write(f, table.concat({"pushSource=", Presets.pushSource, "\n"})) + for i = 1, 6 do + io.write(f, table.concat({"p", i, "=", Presets.items[i].band, ",", Presets.items[i].channel, "\n"})) + end + io.close(f) + print("VTXAdmin: presets saved OK") + else + print(table.concat({"VTXAdmin: ERROR - could not open ", Presets.PATH, " for writing"})) + end +end + +-- ============================================================================ +-- Presets: 6POS quick-change processing +-- ============================================================================ + +local function mapTo6Pos(value) + local pos = math.floor((value + 1024) * 6 / 2049) + 1 + if pos < 1 then + pos = 1 + end + if pos > 6 then + pos = 6 + end + return pos +end + +--- Called every background tick. Reads the 6POS source, debounces, +--- and triggers a VTX send on edge-detected position changes. +function Presets.process() + if not Presets.enabled then + return + end + if Presets.source == 0 then + return + end + + local value = getValue(Presets.source) + if value == nil then + return + end + + local pos = mapTo6Pos(value) + local now = getTime() + + -- Debounce: require stable position for DEBOUNCE ticks + if pos ~= Presets.stablePos then + Presets.stablePos = pos + Presets.stableTime = now + return + end + if now - Presets.stableTime < Presets.DEBOUNCE then + return + end + + -- Edge-triggered: only send on position change + if pos == Presets.lastPos then + return + end + Presets.lastPos = pos + + local preset = Presets.items[pos] + if preset and preset.band > 0 then + VTX.desired.band = preset.band + VTX.desired.channel = preset.channel + print(table.concat({"VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel})) + Protocol.writeConfig() + if Presets.autoPushVtx then + Protocol.pushToVtx() + end + else + print(table.concat({"VTXAdmin: 6POS pos=", pos, " -> Off (skipped)"})) + end +end + +--- Called every background tick. Edge-detects the pushSource going high +--- and triggers Protocol.pushToVtx() to send the current config to the VTX. +function Presets.processPushSource() + if Presets.autoPushVtx then + return + end + if Presets.pushSource == 0 then + return + end + + local val = getValue(Presets.pushSource) + if val == nil then + val = -1 + end + local high = val > 0 + + local wasHigh = Presets.pushLastVal > 0 + Presets.pushLastVal = val + + -- Edge detection: trigger only on rising edge (low -> high) + if high and not wasHigh then + print("VTXAdmin: push source triggered - sending VTx command") + Protocol.pushToVtx() + end +end + +-- Initialize presets from file +Presets.load() + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, + color = COLOR_THEME_PRIMARY2, opacity = opa }, + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = LEFT, + children = children }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, + color = COLOR_THEME_PRIMARY2, opacity = opa }, + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, + children = children }, + }) +end + +-- ============================================================================ +-- VTXDisplay: shared display formatters for minimized UI +-- ============================================================================ + +local VTXDisplay = {} + +function VTXDisplay.statusLine() + if Protocol.isActive() then + if VTX.state.band == 0 then return "VTX Off" end + return table.concat({VTX.state.bandLetter, VTX.state.channel}) + end + return Protocol.statusText +end + +function VTXDisplay.detailLine() + if not Protocol.isActive() then return "" end + if VTX.state.band == 0 then return "" end + local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" + return table.concat({pwr, pit}) +end + +function VTXDisplay.powerShort() + if not Protocol.isActive() or VTX.state.band == 0 then return "" end + return VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" +end + +function VTXDisplay.detailLong() + if not Protocol.isActive() then return "" end + if VTX.state.band == 0 then return "VTX Disabled" end + local pwr = VTX.state.power > 0 and table.concat({"Power ", VTX.state.power}) or "Power -" + local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" + return table.concat({pwr, pit}) +end + +function VTXDisplay.mainColor() + if VTX.state.pitmode then return RED end + return COLOR_THEME_PRIMARY1 +end + +function VTXDisplay.build6posLabels() + if Protocol.state == Protocol.STATE_NO_MODULE then return {} end + if not Presets.enabled then return {} end + local labels = {} + for i = 1, 6 do + local idx = i + labels[#labels + 1] = { + type = "label", font = SMLSIZE, + color = function() + return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED + end, + text = function() + local p = Presets.items[idx] + local band = VTX.BAND_NAMES[p.band] or "?" + if band == "Off" then return table.concat({idx, ":Off"}) end + return table.concat({idx, ":", band, p.channel}) + end, + } + end + return labels +end + +function VTXDisplay.buildCheatsheet() + local labels = VTXDisplay.build6posLabels() + if #labels == 0 then return nil end + return { + type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT, visible = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end, + children = labels, + } +end + +-- ============================================================================ +-- Screen detection and UI loading +-- ============================================================================ + +--- Detect screen resolution and return an ID for the per-screen UI file. +local function getScreenId() + local w, h = LCD_W, LCD_H + if w >= 800 then + return "hd" -- 800x480 + elseif w < h then + return "portrait" -- 320x480 (EL18) + elseif w <= 320 then + return "small" -- 320x240 + elseif h >= 320 then + return "sd_tall" -- 480x320 (TX16S) + else + return "sd" -- 480x272 + end +end + +--- Convert Transparency option (0-5) to LVGL opacity (255-0). +local function bgOpacity(opts) + local t = (opts and opts.Transparency) or 2 + return math.max(0, 255 - 51 * t) +end + +local screenId = getScreenId() +local uiPath = table.concat({"/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua"}) +local WidgetUI = loadScript(uiPath)({ + crsf = crsf, + VTX = VTX, + Protocol = Protocol, + Presets = Presets, + bgOpacity = bgOpacity, + VTXDisplay = VTXDisplay, + WidgetLayout = WidgetLayout, +}) + +-- ============================================================================ +-- Full-screen row helpers (shared across all screen sizes) +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for controls. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +local function createRow(container, label, hint) + local row = container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + }) + + local labelChildren = { + { type = lvgl.LABEL, y = lvgl.PAD_SMALL, text = label, color = COLOR_THEME_PRIMARY1 }, + } + if hint then + labelChildren[#labelChildren + 1] = { + type = lvgl.LABEL, text = hint, color = COLOR_THEME_DISABLED, font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + } + end + + row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + flexFlow = hint and lvgl.FLOW_COLUMN or nil, + h = not hint and lvgl.UI_ELEMENT_HEIGHT or nil, + children = labelChildren, + }) + + local ctrl = row:rectangle({ + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER, + }) + + return ctrl +end + +local function createChoiceRow(container, label, values, getFn, setFn) + local ctrl = createRow(container, label) + ctrl:choice({ + values = values, + get = getFn, + set = setFn, + }) +end + +local function createNumberRow(container, label, min, max, getFn, setFn, editedFn, displayFn) + local ctrl = createRow(container, label) + ctrl:numberEdit({ + min = min, max = max, + get = getFn, + set = setFn, + edited = editedFn, + display = displayFn, + }) +end + +local function createToggleRow(container, label, getFn, setFn) + local ctrl = createRow(container, label) + ctrl:toggle({ + get = getFn, + set = setFn, + }) +end + +local function createSourceRow(container, label, getFn, setFn, filter, hint) + local ctrl = createRow(container, label, hint) + ctrl:source({ + get = getFn, + set = setFn, + filter = filter, + }) +end + +local function createHintRow(container, text) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + children = { + { type = lvgl.LABEL, text = text, color = COLOR_THEME_DISABLED, font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100 }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { type = "rectangle", w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0 }, + { type = "label", text = title, font = BOLD, color = COLOR_THEME_PRIMARY1 }, + }) +end + +-- ============================================================================ +-- Full-screen LVGL layout (shared across all screen sizes) +-- ============================================================================ + +local function buildFullScreen() + lvgl.clear() + + local d = VTX.desired + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = function() + if Protocol.isActive() then + return "VTX Administrator" + end + return Protocol.statusText + end, + back = function() lvgl.exitFullScreen() end, + }) + + -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) + if Protocol.state == Protocol.STATE_NO_MODULE then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", color = COLOR_THEME_DISABLED }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- VTX Settings section + createSectionHeader(fields, "VTX Settings") + + createChoiceRow(fields, "Band", + { "Off", "A", "B", "E", "F", "R", "L" }, + function() return d.band + 1 end, + function(idx) d.band = idx - 1; Protocol.writeConfig() end) + + createNumberRow(fields, "Channel", 1, 8, + function() return d.channel end, + function(v) d.channel = v end, + function(v) d.channel = v; Protocol.writeConfig() end) + + createNumberRow(fields, "Power Level", 0, 8, + function() return d.power end, + function(v) d.power = v end, + function(v) d.power = v; Protocol.writeConfig() end, + function(v) return v == 0 and "-" or tostring(v) end) + + createToggleRow(fields, "Pit Mode", + function() return d.pitmode end, + function(v) d.pitmode = v; Protocol.writeConfig() end) + + fields:button({ + text = function() + if Protocol.isSending() then + return "Sending..." + end + return "Send VTx" + end, + w = lvgl.PERCENT_SIZE + 100, + press = function() Protocol.writeConfig(); Protocol.pushToVtx() end, + active = function() return Protocol.isReady() end, + }) + + -- 6POS Quick Change section + createSectionHeader(fields, "6POS Quick Change") + + createToggleRow(fields, "Enabled", + function() return Presets.enabled and 1 or 0 end, + function(v) + Presets.enabled = (v == 1) + Presets.save() + end) + + createSourceRow(fields, "Source", + function() return Presets.source end, + function(v) + Presets.source = v or 0 + Presets.save() + end, + lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) + + createToggleRow(fields, "Auto Push to VTX", + function() return Presets.autoPushVtx and 1 or 0 end, + function(v) + Presets.autoPushVtx = (v == 1) + Presets.save() + end) + + createSourceRow(fields, "Send VTx Trigger", + function() return Presets.pushSource end, + function(v) + Presets.pushSource = v or 0 + Presets.pushLastVal = -1 + Presets.save() + end, + lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, + "Assign a switch or button to manually push the current VTX config to the receiver.") + + -- Presets section + createSectionHeader(fields, "Presets") + + createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") + + local bandValues = { "Off", "A", "B", "E", "F", "R", "L" } + for i = 1, 6 do + local idx = i + local ctrl = createRow(fields, table.concat({"Preset ", idx})) + + ctrl:choice({ + values = bandValues, + get = function() return Presets.items[idx].band + 1 end, + set = function(v) + Presets.items[idx].band = v - 1 + Presets.save() + end, + }) + + ctrl:numberEdit({ + min = 1, max = 8, + get = function() return Presets.items[idx].channel end, + set = function(v) + Presets.items[idx].channel = v + Presets.save() + end, + visible = function() return Presets.items[idx].band > 0 end, + }) + end +end + +-- ============================================================================ +-- Widget lifecycle +-- ============================================================================ + +local wgt = { + zone = zone, + options = options, + wasFullScreen = false, +} + +function wgt.background() + crsf:poll() + Protocol.tick() + Presets.process() + Presets.processPushSource() +end + +function wgt.refresh(event, touchState) + wgt.background() + + local isFullScreen = lvgl.isFullScreen() + if isFullScreen ~= wgt.wasFullScreen then + wgt.wasFullScreen = isFullScreen + if isFullScreen then + if Protocol.isReady() then + VTX.syncDesiredFromState() + end + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end + end +end + +function wgt.update(newOptions) + wgt.options = newOptions + WidgetUI.build(wgt.zone, wgt.options) +end + +-- Initial build +WidgetUI.build(wgt.zone, wgt.options) + +return wgt diff --git a/color/WIDGETS/ELRSVTXAdmin/main.lua b/color/WIDGETS/ELRSVTXAdmin/main.lua new file mode 100644 index 0000000..ee2548f --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/main.lua @@ -0,0 +1,43 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget -- +-- Displays VTX status (minimized) and allows full VTX configuration -- +-- (full-screen) via the CRSF config protocol to the ELRS TX module. -- +-- -- +-- Uses the loadable.lua pattern to minimize memory when not in use. -- +-- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +--------------------------------------------------------------------------- + +local name = "ELRSVTXAdmin" + +local function create(zone, options) + if not _crsfSingleton then + local getCRSF = loadScript("/SCRIPTS/ELRSLib/crsf.lua") + _crsfSingleton = getCRSF() + end + local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + return loadable(zone, options, _crsfSingleton) +end + +local function refresh(widget, event, touchState) + widget.refresh(event, touchState) +end + +local function background(widget) + widget.background() +end + +local function update(widget, options) + widget.update(options) +end + +return { + name = "ExpressLRS VTX Admin", + create = create, + refresh = refresh, + background = background, + update = update, + options = { + { "Transparency", VALUE, 2, 0, 5 }, + }, + useLvgl = true, +} diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua new file mode 100644 index 0000000..22e38be --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -0,0 +1,174 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 800x480 (HD) -- +-- High definition landscape (TX16S Mark 3) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local Presets = ctx.Presets +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 800x480. +-- Reference zone heights (no deco → with deco): +-- 1/6: 69→~58 1/4: 104→~87 1/3: 139→116 1/2: 209→175 3/4: 313→~262 +-- Thresholds must work for both decorated and undecorated layouts. +WidgetUI.breakpoints = { + topBarW = 200, + sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) + thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) + halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = MIDSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = DBLSIZE, detail = 0 }, +} + + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, VTX = VTX, +}) + +--- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 400 + local c1w = math.floor(w * 0.22) + local columns = { + { type = "label", w = c1w, color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, + text = VTXDisplay.statusLine }, + { type = "label", font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: status + power, cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + }}, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + status + power, cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + { type = "label", font = WidgetUI.fonts.third.status, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + MIDSIZE status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.half.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + DBLSIZE status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) + else WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua new file mode 100644 index 0000000..9ffe97c --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -0,0 +1,264 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen, widget zones differ significantly -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local Presets = ctx.Presets +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x480 portrait. +-- Portrait widget zones tend to be wider-relative-to-height than landscape. +-- Height tiers are scaled for the taller 480px screen. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 70, + quarterH = 100, + thirdH = 140, + halfH = 210, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +-- ============================================================================ +-- Minimized display helpers (portrait-specific overrides) +-- ============================================================================ + +--- Shorter detail line for narrow portrait screen. +local function detailLine() + if not Protocol.isActive() then return "" end + if VTX.state.band == 0 then return "" end + local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({pwr, pit}) +end + +--- Build two narrow cheatsheet rows (3 labels each), or nil pair. +local function buildCheatsheetNarrow() + local labels = VTXDisplay.build6posLabels() + if #labels == 0 then + return nil, nil + end + local row1, row2 = {}, {} + for i = 1, 3 do row1[#row1 + 1] = labels[i] end + for i = 4, 6 do row2[#row2 + 1] = labels[i] end + local vis = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end + return + { type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row1 }, + { type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row2 } +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, VTX = VTX, +}) + +--- 1/6: single row with status + compact detail. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local columns = { + { type = "label", w = c1w, color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, + text = VTXDisplay.statusLine }, + { type = "label", font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = detailLine }, + } + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: title + status + power + cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.28) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + { type = "label", font = SMLSIZE, align = LEFT, + color = pitModeColor, + text = pitModeText }, + }}, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + status + cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.28) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }, + } + -- Status + detail row + rows[#rows + 1] = { + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, text = detailLine, + color = COLOR_THEME_SECONDARY1 }, + }, + } + -- Cheatsheet rows + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = function() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + local pwr = VTX.state.power > 0 and table.concat({"Power ", VTX.state.power}) or "Power -" + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({pwr, pit}) + end, + color = COLOR_THEME_SECONDARY1 }, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) + else WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua new file mode 100644 index 0000000..d299607 --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -0,0 +1,173 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 480x272 (SD) -- +-- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local Presets = ctx.Presets +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + quarterH = 70, + thirdH = 100, + halfH = 125, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, VTX = VTX, +}) + +--- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local columns = { + { type = "label", w = c1w, color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, + text = VTXDisplay.statusLine }, + { type = "label", font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: status + power. Row 2: cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + }}, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + status + detail, cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, + color = COLOR_THEME_SECONDARY1 }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) + else WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua new file mode 100644 index 0000000..9e18ee1 --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -0,0 +1,201 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 480x320 (SD Tall) -- +-- TX16S, TX16S MAX, TX16S Mark II -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local Presets = ctx.Presets +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x320. +-- 48px taller than 480x272 so widget zones are proportionally taller. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + quarterH = 62, + thirdH = 118, + halfH = 147, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, VTX = VTX, +}) + +--- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local columns = { + { type = "label", w = c1w, color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, + text = VTXDisplay.statusLine }, + { type = "label", font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: status + power (+ pit mode when wide). Row 2: cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildQuarter(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local row1 = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + } + if wide then + row1[#row1 + 1] = { + type = "label", font = SMLSIZE, align = LEFT, + color = pitModeColor, + text = pitModeText, + } + end + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = row1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: three rows — title, status + detail, cheatsheet. +--- 480x320 has enough room for a title row. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = {} + -- Title row — 480x320 has more vertical room than 480x272 + rows[#rows + 1] = { + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }, + } + rows[#rows + 1] = { + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, + color = COLOR_THEME_SECONDARY1 }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) + else WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua new file mode 100644 index 0000000..8861802 --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -0,0 +1,226 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 320x240 (Small) -- +-- Small color LCD (PA01) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local Presets = ctx.Presets +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x240. +-- Smallest color screen — everything is compact. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 38, + quarterH = 54, + thirdH = 76, + halfH = 100, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +local function pitModeTextLong() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +-- ============================================================================ +-- Minimized display helpers (small-screen-specific overrides) +-- ============================================================================ + +--- Shorter detail line for compact 320x240 screen. +local function detailLine() + if not Protocol.isActive() then return "" end + if VTX.state.band == 0 then return "" end + local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({pwr, pit}) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, VTX = VTX, +}) + +--- 1/6: single row with status + power + pit mode + cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.22) + local columns = { + { type = "label", w = c1w, color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, + text = VTXDisplay.statusLine }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + { type = "label", font = SMLSIZE, align = LEFT, + color = pitModeColor, + text = pitModeText }, + } + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: status + power + pit. Row 2: cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + { type = "label", font = SMLSIZE, align = LEFT, + color = RED, + text = function() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit" or "" + end }, + }}, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: status + power + pit mode, cheatsheet. No title on small screen. +--- Fixed-width status column prevents layout jumping when values change. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = {} + -- No title row — too tight on 320x240 + rows[#rows + 1] = { + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + { type = "label", font = SMLSIZE, align = LEFT, + color = pitModeColor, + text = pitModeText }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = SMLSIZE, align = LEFT, + text = VTXDisplay.powerShort, + color = COLOR_THEME_SECONDARY1 }, + { type = "label", font = SMLSIZE, align = LEFT, + color = pitModeColor, + text = pitModeTextLong }, + }}, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + align = LEFT + VCENTER, children = { + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + }}, + { type = "label", align = LEFT, text = VTXDisplay.statusLine, + color = VTXDisplay.mainColor, + font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + text = VTXDisplay.detailLong, + color = COLOR_THEME_SECONDARY1 }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) + else WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua new file mode 100644 index 0000000..c9edb7e --- /dev/null +++ b/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -0,0 +1,40 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - Shared Top Bar UI -- +-- Used by all screen-specific UI files for the top bar layout. -- +--------------------------------------------------------------------------- + +local ctx = ... +local Protocol = ctx.Protocol +local VTX = ctx.VTX + +local TopBarUI = {} + +local function getStatusLine() + if not Protocol.isActive() then + return "--" + end + if VTX.state.band == 0 then + return "--" + end + return table.concat({VTX.state.bandLetter, VTX.state.channel}) +end + +--- Top bar: ultra-compact single line, no background. +function TopBarUI.build(w, h) + lvgl.build({ + { type = "box", x = 0, y = 0, w = w, h = h, + flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = CENTER + VCENTER, children = { + { type = "label", font = MIDSIZE, align = CENTER, color = COLOR_THEME_PRIMARY2, + text = function() + local s = getStatusLine() + if s == "--" then + return s + end + local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + return table.concat({s, pwr}, " ") + end }, + }}, + }) +end + +return TopBarUI From 40aa9a52c635af1a2598b080a08a1fd3fd10a033 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 13:58:41 +0200 Subject: [PATCH 004/218] de-box --- color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 10 ++-------- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 20 ++++---------------- color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 15 +++------------ color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 15 +++------------ color/WIDGETS/ELRSVTXAdmin/ui/small.lua | 10 ++-------- 5 files changed, 14 insertions(+), 56 deletions(-) diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 22e38be..8cd92e7 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -92,10 +92,7 @@ end function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, @@ -137,10 +134,7 @@ end --- 1/1: title + DBLSIZE status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 9ffe97c..eccc469 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -103,10 +103,7 @@ end function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.28) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, @@ -143,10 +140,7 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }, + type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, } -- Status + detail row rows[#rows + 1] = { @@ -179,10 +173,7 @@ end --- 1/2: title + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, @@ -219,10 +210,7 @@ end --- 1/1: title + MIDSIZE status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua index d299607..760c39e 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -89,10 +89,7 @@ end function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, @@ -114,10 +111,7 @@ end --- 1/2: title + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, @@ -136,10 +130,7 @@ end --- 1/1: title + MIDSIZE status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 9e18ee1..814b8b6 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -116,10 +116,7 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row — 480x320 has more vertical room than 480x272 rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }, + type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, } rows[#rows + 1] = { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, @@ -142,10 +139,7 @@ end --- 1/2: title + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, @@ -164,10 +158,7 @@ end --- 1/1: title + MIDSIZE status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua index 8861802..f9595c7 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -161,10 +161,7 @@ end --- 1/2: title + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, @@ -189,10 +186,7 @@ end --- 1/1: title + MIDSIZE status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - }}, + { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, { type = "label", align = LEFT, text = VTXDisplay.statusLine, color = VTXDisplay.mainColor, font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, From 66cc28e04329fa8c430fe20fb579359691cc8d94 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 14:57:35 +0200 Subject: [PATCH 005/218] Add readme --- README.md | 83 +++++++++++++++++++-- screenshots/tool_main.png | Bin 0 -> 37679 bytes screenshots/widget_telemetry_fullscren.png | Bin 0 -> 31044 bytes screenshots/widget_vtxadmin_fullscreen.png | Bin 0 -> 26645 bytes screenshots/widgets.png | Bin 0 -> 22713 bytes 5 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 screenshots/tool_main.png create mode 100644 screenshots/widget_telemetry_fullscren.png create mode 100644 screenshots/widget_vtxadmin_fullscreen.png create mode 100644 screenshots/widgets.png diff --git a/README.md b/README.md index 12a229d..fa83f5d 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,80 @@ +# ExpressLRS Lua Scripts -`elrs.lua` works with all versions of ExpressLRS from v2.0 to current. There is no requirement to use an e.g. elrsV3.lua for 3.x, just use elrs.lua. +Lua configuration scripts for ExpressLRS on EdgeTX and OpenTX radios. Two variants are available depending on your radio's screen type: a **black & white** version for legacy LCD radios, and a **color LCD** version with a modern LVGL interface and widgets. -## Old ELRS.lua +## Black & White Screen Radios -When copying the lua to you handset, delete any old versions such as ELRS.lua, elrsV2.lua, or elrsV3.lua. The version-labeled filenames have been obsoleted. +Use **`blackwhite/elrs.lua`** for radios with a black & white LCD screen (e.g. RadioMaster Zorro, Boxer, TX12, Jumper T-Lite, etc.). -### Downloading from Github: -Click the file link above, find the "Raw" button near the top of that page. Right-click, Save link as.., copy the .lua file into the /SCRIPTS/TOOLS directory of the SD card on your handset. +- Works with both **OpenTX** and **EdgeTX** (all versions) +- Compatible with **ExpressLRS v2.0 through current** -- there is no need for version-specific scripts, just use `elrs.lua` -### Downloading from Configurator: -Use the button shown in the image below to download the .lua script into the /SCRIPTS/TOOLS directory of the SD card on your handset. -![downloadlua](https://user-images.githubusercontent.com/68074253/129203116-c1234719-3e8c-4cbf-a391-b7fb8dc0262d.png) +### Installation + +1. Copy `blackwhite/elrs.lua` to `SCRIPTS/TOOLS/` on your radio's SD card. +2. Delete any old versions such as `ELRS.lua`, `elrsV2.lua`, or `elrsV3.lua`. These version-labeled filenames have been obsoleted. + +### Downloading from GitHub + +Click the `elrs.lua` file link, find the **Raw** button near the top of that page. Right-click, **Save link as...**, and copy the `.lua` file into the `/SCRIPTS/TOOLS` directory of your radio's SD card. + +## Color LCD Radios + +Use the scripts in the **`color/`** directory for radios with a color touchscreen LCD (e.g. RadioMaster TX16S, Jumper T18, FlyDragon, etc.). + +- Requires **EdgeTX 2.11.5, 2.12-rc4, 3.0 or newer** (uses the LVGL graphics framework) +- Compatible with **ExpressLRS v2.0 through current** + +The color LCD package includes three components: the **ExpressLRS Configuration Tool**, the **ELRS Telemetry Widget**, and the **VTX Administrator Widget**. + +### ExpressLRS Configuration Tool + +The main configuration tool (`SCRIPTS/TOOLS/expresslrs.lua`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio: packet rate, telemetry ratio, switch mode, model match, antenna mode, TX power, WiFi connectivity, and more. + +![ExpressLRS Configuration Tool](screenshots/tool_main.png) + +### ELRS Telemetry Widget + +The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). + +![Widgets on home screen](screenshots/widgets.png) + +![Telemetry widget full screen](screenshots/widget_telemetry_fullscren.png) + +### VTX Administrator Widget + +The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. + +![VTX Administrator widget full screen](screenshots/widget_vtxadmin_fullscreen.png) + +### Installation + +Copy the contents of the `color/` directory to the **root** of your radio's SD card, preserving the directory structure. When done, your SD card should contain: + +``` +SCRIPTS/ + ELRSLib/ + crsf.lua + TOOLS/ + expresslrs.lua +WIDGETS/ + ELRSTelemetry/ + main.lua + loadable.lua + ui/ + ... + ELRSVTXAdmin/ + main.lua + loadable.lua + ui/ + ... +``` + +The shared library `SCRIPTS/ELRSLib/crsf.lua` is required by the tool and both widgets. + +## Compatibility + +| Variant | Firmware | ExpressLRS | +|---------|----------|------------| +| Black & White (`blackwhite/`) | OpenTX or EdgeTX (any version) | v2.0+ | +| Color LCD (`color/`) | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v2.0+ | diff --git a/screenshots/tool_main.png b/screenshots/tool_main.png new file mode 100644 index 0000000000000000000000000000000000000000..41c01018154562e8460c69f01b00ed91b4d420b4 GIT binary patch literal 37679 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�_wdu(b z%{-K(hM43`g@9m;dD%VY-jPa-8A@&*x=8{Ftl{!h8fAsCp=+Dr`qMLJX`n#}c=#e` z&(h5HB4Q-JJSU|?kaH{);h+cS;LmDmb}t@dZdR}mUB2jPyeCNbDCU$mJFpt6hb_D< z%k`7C&*8VzKJ<8#DNp#q8FA{koDvy1Q1m*$QdWYtL-6+p?)?^)otmdj?&w%lo%JCQI8-wKXORahP`v>xLC$Ly?f3YPG2|kbd1Oliuodc?+Z36^}f8P z|AL~kckE64c5DBjf*XofnIWOLX2QFvIk&pO&^FWM6AdJHF11~b;2Sdd5PKch{+kc$ z>5qVDF#cr8rj(P6YUp~Shb2*(ipgeLhPG@(WC`gDHH`J;t*=`*WI?YuP~#^~jZa?x z>~$bI1xdHQx(#?_$AG>2GmZ?yhBMe-o8AwqGKQEw>HGAX0q8qAmrB&$`b+6qRvRwg zam7Zm_AT(mASRnIAxO@-gry1dRHUqcrJF#zeOOlNEr0qg`+Ju!1nNfRfLJn$9iW9pgPx!^dZWJ`gMUPjPpmZ#!5lLWsn34&1K*Ms?swG;t)D#fBqlmeZHib zh~CdzxtX3OTUSDt8;lg9;-Nz5_{cwlvet7H zh_vJSrd8OeFQ{08BW;y7Ch!9?p3`I+f#Hl^{p*d1yq||bFujhqM-u*$N!8H2 zpEM3sdVqPIHW|`QzoPn;O4#}k1kc?URDtr%b+pd5^MrQiMKQbC9_Wy3qKmuPH#6Bh zlo>sr-rw}6R9le92<>FP(w>U3G2m05C!7l}T7;!bZ{|~oiQ<9{1g`2dcc%K#X>&O$ z7!cMv9ha&Sf~#DHBOi7Y4|b1S_B3e=8};%1b944TJRXMWYRoJe&!(xr^=aJ6J;w~z zuG>}wkn|-YUToKZT^NTTo4|C#g}?HpoWMarG-xw1(PUMxWWB?S&TsYib!_}u6ybZN z$sx~PK!7zC{Ao=kuP0i!@s}D{pYjc4Ag=eDg?awcgF`b2Bp{&DCe_HMA#}OU^!SKX z-+=|R`Fz=!O1)DxY&3SdiS}ZiD(suMz6RS2R`7wT7W%&V_F19LkZLAg1-yVbui2Hm$5|$XWErGF7W${$nI5N9= zJ={6>Re7>Xn9DVegdJ-(;r$IH6>koyb*%%+h}iFB2$g=$^+kyq@#_Ed0=yH;f3=`% zH>&%}|0I-f@(Ld=1P=u{mL*Y^yP7Am&+EDlQex&nrDgJ*r%8+hcCN}H(XaU-7pm9U zW>g}^OE##dU9H+BcB{?LUEZCeFBN1$o%e&+cZtbhC4Levgf{XrtpB0}_s( zyinu0b|jGjn?TC*1aBjqw_qvpJ-*G^!KR1J(P6Qb_=Lma|#8_C&Gmwoe|IaFZ2g=jT!k z(3C`Mx<5*YxHyvo3~uccsm0gVKV6Q_^KnOwI?v8|K!NiXRUEf%J+xyboe&45L)4FT zuHqq!=?WX}Fw5B0LSu`&z{=2tt;)16nL`*9bG@H5qi!yqaVnC+a~We#d@dHB^zfF% zgq`qwRsy#WarfChUhJ#}>eP=%>xaWHB*es~Ao_KXn;Op03V>%5laVDGrZ93qPUImQ zUSqZ;PzDmIc7NoDjPLMI6!YDhLjSHw*4i$d`Gmbn)zyx=DPTDY#EX!39!*rCw9#bc z#Aq?o2QLJn+RGb&iE6{eqog!nlS;G0*fuM0zym05)a?gD%#fTMkSMFv>AJQL-bwE% zog<~D(fqpnZvi9;y4kN<^tx&J@gh7zxnzNa&-QqV%%13*Tb1FWzf6sL$Axr0b0b34 zu*UntxRFPiZDrN$6-xvV3Ey(Z52);-g^|bff#PXCSztpZocqD4XyH=h{1^T`OR6jz zSyR@a&Vb3}P6xX|E@YK|+d<40!=dVLFKZTWBgC=`YS{b$w&Y(+RrZeUj@V&rua%aU zOJG5kBkF?O_iZ;5n+KqHPjm4*GV9y{^!fHdVaQNrqpbXqWv@%7k-hk3RQ=OD^Ly{I zY%O8rF|BH4za%dneuXy@g|}o-nlnwK6XFOSG3Vj2DM0QvJqzhlY09mDdTv{^myWC# zYS#|micle>!dl<1TsRz4o~-^N+`4SGIY}34+lfmmke}_eAM0=Sf+7b?zSw9xviGvy8go0kG=3Fs9wdZB&Cb<2d+1etdNM8SN5Joa~g_j_F($0cVs#3 zC?S%8?+@fXPO!MBR7UCevn8x0mnVusi}hP0mog;xp)vc%LI5T94Ttg^v-^#{uP8Q% zTu~!#Vao@BEA*|T>O*4|2XW|ej8bk!oqIk-Yr^`W!NiW_1B?CyI+<-NBRV53Og<;ff%m z1pBJ@3>8a0Qour8uyu3&&<+vyz|+3|JZI{h^ZMG%>`D3lZ!-C2=8OmaI9olO$z=i3 z(i+b!G`ssgCZkbUAL~@CGEGJHWL|Yfl{?_s!KSt1et~?L2ko(p7{+1BFA-VlID7!o zJmq8|0G%L9I!B@dhs1$bfGemwVPo1G6MUqFz%!FwHJ59&=m;a6+a<@wq8)`HRvCsH=_Z!;t<{6A?i{-5ylzJpoOi#>YmB!&>%* z)0x|&qu!YJuUOKLZ%5VlpUvPkb+N6pa^OZ)VvVZGAfMH6XO{4$prM9>`DuEOs@Z<0 zvsbX3A%w7rgXW5by3K|R%{qJLXj^AH+lqmW3$<(-AK`y4RzBEyB!YX-LZS7=&!xRS zYih2Q$ql+OP1=5%5FeG+FhX^!C6Bg|zVIisL%w5~5;qw|Gi+~GsPYE>C+qS5HKD-j z02)nzliPpJWJS2=6*dy*Z^;eJX)AVFVIOpiAbIv>1G-0BRV(Z^uE!r7wanw67qu)c zF)`%f0p}W=>b8T2;I8+iDkY#42EF5E)zIHoVy|A$NEjgq+KC_A1w`(;$yi7{!y!y< zlU9`m75w6=cx+-(=a-lxtV=~K7{`4%zR$NdXQt4Y)~b~xst^;^7p5R_a1FEO>iq#w zKWAlU{eF&tYxGm8*hPJc6nH3j_{rvWD~MFHcG-`xr0N3uSKWGkfEUR^Zgrum?tZG| znaW9WL5N45{Y=T$lp`i*Om58;Mca9Mz(RNyU=aL(yli^(;tue+Ksx@Bjeyj$@L3p@ zb$y_6ZWyt>0$$4O6FIS-JVmdQ_=dE~2az2fs(|t(>ojWn5u_g+SZ(sPO(U0Iai3}_ zPP>Plz*0`D-UExw0&OVZ@7J5J^4Fg$k{Dk&@A<|mU};_7*G7E6g~X~CrV#11Kie8NxGZ%u1FJ+E(AYCbAAgg5_c?f9{VP!Yn2kdTM*o?F>blx0y-fE&Ce#M9DGDNvi!0+Nww7)&Wt{>-* zM$p~Swg|?I{iG~`K`o3=p4F24`gK8glYRC80|GKI%bu9do4r$0OIFU;=h9(rX(UfhmVaxfb$(gF z{w%d5a9mKNMW^iUv)hS_FA%(-usL;@kI1?`A9!*oNF8&%(@V&~#7$|$zw|~M1bRAd zmug3*rzkAvaB3m;u?*`)A(%m-T&0;-&xr`xPv*9?`Y%k6CK!a{>^WTNa62~aQYSy_ z1E$ZKJ}B$ZHF@+?G$8SSfiWM5B~OrbhQEx44Mo=fvL>T zE%={J-ud4xVOW-SGv7R6Hs!afY_SMXkiMMwtUmR`1)Bx6o96uOCl6&d^MMER;FLB! zIDSI_a19#l1`K8a{R%zqzEi{}n)BYu_3Ogba=SKOy z7fE|QObl$rfd53V;RQ}5ssPcD$or!^Z@u@4^f zZfha7{JHJh<``LOvpXj^-%R?$kEwx*tG;i(pc>{8XD9B8VcbVqsjBI~ZGa0*vbjGV zJZj(R7>inLx57hcTT3FFE0-NHX}x`}gomXEwPAVf*#?rDtSH5o4w7F}Rh?B#0UCut zuGbuE59b7Lp50widXa{9%b%A3@_A-{hBh771i-3{V`bmV?Ch?Gwm1EtML+?Ppxb6} z;q8c8ePk|0YP{*$5x`z(sMa*LH_7~NM7A3Wjs=So`sD`vQ%@6hqvb)GM466cSr;84h~@w%M82Cf_X_}anbnrSoG)r$(-fO$LK8u{Ra zR!%bzXyG>$GQg5Y2LUj{?Y_(Al1gUEPrXyDO>V&H0uq)87PQ7%9w$cx0p_8gEkhuZ zaML952odv$0qd~{;BAsuJitEzLK>x1&(1qqshC?XNZsa@I-EZ(ZFq7cd_S*QQh}H5 z1#i?EUkIQY&>?P_N7SQH0>oUczk5aYGWoElAu%F{5TYwvzmZLxJ$&lku@FMY=O_ly z3fsQ4yteied`W8#gRxy+wNgqBfNls9*xo+F5BcP-2sfmj#Tj_j4p2VG^_^=hb+p|0 zqx|X1`nfZYJ|Vp0rKPE3YmuukV_K#iN%{Jhk(&F!Uc7IST*~@x@4*wiT)_!Gnu^v< z38uz8EV^iDp50^2J;0#-IpGg4VgTi-s6NU9v7&|HA(8I#@c}icBVgA9(bEeES@~oB z+cT>JQk!q%Ic#S7b?tVYW^`~->6Hrxr1%6|X-vzXWpgD4M|XyLE~u9FY4hQi+%40D z#85oIudN~Qsp{AjGE zTGt##kelao8)QF79(UBNE3+7Xx8b92SS0-5>X6@?HCc6MZ5c+x=$-G2wFj6KFJ(r|g&bs)eLauoTSeMcbkkXwc8#y3!Ir{A6RrgHo7L@iO_(qt3^fSw><^%1!C(+btb6%YjU)mZevHl4b8ilKj7tkxUN zVb%LYdUMsWUxVZNeBq;aP^nw*`kb5L<|_7Ql~2H1VLN9v0s+;~_V#;S z#W>@K?{;G#6(IZv-CN}t@`iP==zB*}_Q;)>&w$YR!;^aNms1arnemZz=$m7{_i5y! zE7v}=Imi2^cMR1HHHE&wqyrKm;1KC?CCtCfkz&@ih{nDjH6`*Wv!YtJoBWQ4|FxQ< zOT^oT7~}`wN+-TKC3vTuoNR=VuOiu;>DE^1C)S*n;XG}bJs{)Oz>ef?`Ge1q6pG5U2#Agos*$CT;Py8*?sDvjBrjiI|QzoXu$?y=3vB*SHN9Qqo+^ z*8AW4w4vVTW!rShn@3MsHAfS4OSU;_78I-Y9&(>mE!)gIjqHDpAHfy&D5 z7u%lbLz`%ihnN}rcp_qz^TgFm+D};3Tj^!#+xw+_(Ss~q?<_>(mD?{H^!LY4RVfY% z@Ib@tCfc3!XnR@f1R`BF?mupC;AL|*l*3aeuWLwa-hLEaA3VKEeU;!;vBCjDF2lQ! zZgGVRUQ77-K+AZ&V}Dlwv&cu`+(ZZnoY0L1}1rnuD4`^L$?_4sY~v1W;K z>x7M0oNuLSg+Cbzwz2iI;m*C-&z(sqVFN%-Wm*pd7=Ln0af@oo5(;n#ys@7fTfC@5 z%*ty99hpanCCMAxzzpyZpNtY(-^g%FP#5et9BXzQO+jBYx;C~wu5QEaXaz3tm{m)JF@}Wwr5;B74Pa$4@}ZVCGJz%Ly`4Jch z_gvQi&l{_m3tkHl8U~kFz3?5{z?$W#@8SH_ARbYC$A7S_Kx3doBs+VbWtPF@9X3EM z0{jS=pq`%4i>l*mfKP{fsX`Mq#sH2CxZZQ({q+!pZ)|4oBp+}0ubr9T*q}+j93n5)UCgT+X@0ZH4;58odMh&+Gw2tNFUM?!fe z9(~M~{7C7K@3A|s6d8a_8k)${o;*7sdfQ@+wX?U)>isovepO{OU$U+m^Dn%2v(wqL z7#qrC$nJMTVsbVAOC)Cj6Rp#(`c38pGTC2AP`P|^6wP|_#{{3og)*G_^jtn503TTx zqepq=JEF&9)^Far0&qk+c0v7$g{3EX`j;0><61HQ!2k^p5+?hRfum`8F7lN@pY>k9 zV41?T)=l^Nm3%+)j-Z(}TQt}cd^5F@?tq}h09NVf+B*B^WIdc5R2ZJXbb9j8swanX z?4*_gm<0Ow*NY}Q>+{C^mD;8wfv*9aUgE10ck6g<|1t%X5S-JHcNEuV-*KgVkM9;4 zdvqcQEfJsm?KAxyu9QY%M0E!wvt_kQf5t|Y2o0eK7S(zY3>T4!ntNKhxNKaad=u`k zzRDvv*<*Mxq%^$GEhPqwP@243B|xcT?ysak(-djY#Nare(H4S~V85qF-?YSo(%IQ^ zgR}@S(S%O?tq*%N?oO&Y2pc-VcIp>yLLzzpT|tjJLe#z-2+ViM4aE)3A|9=g_j{}O z=+Ay3l8?Eh-H?Ad5)D(w{vdEZV%Dv}WxffVU;%y0^dixc2u-!{quIiK-WBjCpxqY^QHH zE6Ph}X|!?+)D|Y`^PW8_C2i`O|I=qWub1;*3878B&NCkk-S&_cD~A4;xJ$`B?OLC$ z7;Z@*TVyB2ivC@S;ya~f4yJLP=QPZSiZ2bRzjv$aJuDEpzp_kl*J7PHOJ+ z(2i1V>4iWPb5?7iRu_2Ci#5an!{scEkU}qL=wPSHtGSA3@_sl>+Gq+8%xg<#hJh&Yhf$FwOcNpT!o&&g8V+^=Y%)d) zuX##aJ#?`$rWTff2)ICP2iQ96!F8L?h*AGNOxnN`BR_Y<9;@{XIE7kmhBA%lCtoTf zYm_meZ2{ndCg`4auJZTm3gg=h4AnjVIMGr6Ma9(k*rQYb%Bc<_VzTJ}KWmAj`T#T< zcrzB3Vq3z<&0}s@>z9`3@)qu+tSEU43BV1ozfEVk`4h4GVp)R>buMbd#jUwnO1hz{ z1w8?Dd?<7pL5ui zaG(Pc5)Do!MQZfx&G}3cA!1dFpLHZn8tk2}`w81T=dyPI&jEV(imL~ct|PR|(wLdtPzfF)~ulJ2>*L??QeSwWIq?DSEb# zsOehS7{PUO*-r2CBPqLzBncr=y%TUZ>_%66q#hh@d5tfB3;$~K@$Wi!paM&MdZ!ZM z)N`tVVQ+ff{s=hhwVtaJqs|AU?EWYtdkrHe(vt@(#I3hH(xSU@^^*zMX=SA2w40i} zQd4KpOHUZ5YixM!v9X1zd#t#{b4)t0IuS9#uYiZ{J|7F?2Lg!IF z)U$(PuJ5#r8z-L1NNdCZKm9L%9;1B|V_k(#aAX63eSC{IxLBvwODYLJWX9(=oL z#Xy}}0;hmM@$3Q!r8Ysr*u-&gFFz68*t%W3g;0by>w~Q{s?OxcZwXF41-`i5DT$sJ zdYyHy8IBx}(;w)+paZ9Cf!0supW@@41isPG+`%ALZK}qm1LhRq?z+R5jZKf3{v{6} z*%4CZrzkh)CIzEa`1sYKGVnDm-vU$tkf%@)edizfhr?Xjp}QCQii7T%4;FYwtweMe z=29K8Wq?GluGJHq2CRVucO*7(dtgDuEf?V_5^C!p=G4VEY%Q`(@QMAD>lSS1OyA1BdMwSqPLmHsX{q{){oX>c;M<=bTkPB>nfNBQ(w>igWg7CWRIR_lR zVm*bmJFg7?(+kjSPa(dI0g3T--&zdQ*3ty~Mk`)bLW?1NR?koF?Bb&C)29!SESiiV zJiw;5dq{ueN4i|=+_EKw3HdY70!MoR=jv7>X3T5WHhMzW4TdjElwc&!vGNfW6Z>G0 z)}Hlq8YYHNGo}v;>OO!ESYug|f6UD7>rDhCNc}Q1ELT33Q*ku;If|2b5L%@%XR_h&He=Yb;gYK zkGJzWkQFeSU}@g`y0;`G=w1asm6g_bBx7i-q@WqpV?2toMSm$A5<&LZO#~Afs?`}K zDTuczedg>)21aCX2%xw85GTREq|3=VXc!n*h2zR_xItU}@BBX-CK@=$m;2+D1-W+A z@^^GBtE}$T^lk3}EZ}-ubl`iCu76v0BQ(0#JEZ8O6UGb7V!)om^+U(|wJ$hQ4h#%c z>C=LXZWmAU(`y;?d{pLm>3(eWLLgv1CLwzB9JwA22{c0DddX(B*>K*vhA4)(EI0%* zYlkRW#eg&!{X&OG-`wrr8+OQH`d~NNR4XC{Xt(F{tyyX04h%-#bMYR_B396)F+MUt z>_N{o%VkIIV2o8v_DnS_d!0T8sTfSoV4E~Ih~@Y0*~AdYf2HSsrVl40mI!ToA7sW_ zWEMR+*JDH-LIgAmkUhuUUj;v?1Q`^7ykNV%i`5pu+XGV{2s|M>8Tls+=rw+N*oBk_(JkQhFGR`O`-WaQQ4$!mr%8Ga-uIBTJd-7YMbBm|Gb>| z_6uq|kFkN{^PmI8tpoB1%!QRX6`h`&_MgU^cVa?zb$jtO z({VLi*6oaPR>PCiK>N;tc1Xay=KL7)Og}o{X_+ce4~cJHOHK>S{bGLM72~3?f+M{` z((StcYT8B_(6^m}7etp#kSC)`S$_(Ri;v0_g?k+lpX@FlZmr!_{fPqk04fvfjrn6c zWW;H{+|Nx1zG=CY+pj?{4GfAvs{)*;^@%n0y2HtZTbKHLPVi-zt=0D2`? zW6?E*Ta~GALFjh-_Tto7<5XWzpQ5S$AIugm=205iTg@rFvF!a0Er$?m5}rSuk*b0~ z7i$QSD&_(QT5JqekDMt_@!w) zot-K2=yuY@q1$POX#;F@;kdI+O;?oOoF%aYLjzX7Y^%~4demU4h*~Lx&lCJUuVumBRYCxjeXu7j!L1vcmRbR1ahub% z#dmtG!EM@onGzG)3Xgvrnfe91U!58XQ#i5+L~{^Mrn9Cd(DPGpFR%2b9u4VimX+XMyPjlf?yxyiiD%d7Ire)4Oo;=!;JNRrS&r z(-|Ponq2^Ap-$W8=1h8}@yc7ZW@XwI@eJZiI5oMWLF&6m>t;aXg<;cfQsLuRf8?zkNKF_!Za?9_d4X>ovIDINi|=D3EIaXXF5N7)1@mT^&7z z0GG=m%;w`dMbh^-dACpWL_mF3H~Ef)p+O{oaT!G)0on)C=fQl`+GNP*%5t&9#m}la zr+|D(1TM&RaKDW^wFOIel?mH1Bo>|;wkxBq1$rEitPVz+trRbeq9W@VL~&Uk9=={s z#q!c!DB+&LjtKjx_GOyddqI#C)69xRANzM6eDALfwa}*0|^A$?S$j4djU5*7Z*5ycV>JBUy|2>5CMDF7j98hT;IM%s4iw4ornew8e82_)g80#Iut)$el(_w z!q2+xEcZI{<#+5$Ixv#TrxkttBtBI=Gy2r(88+fX749MVg2$t^t*b(mJtt~w$8DG= z!?NPvc>VI3q!C3+QWnKatFm^VK4o8ihpqN8ZI>%t zGX<%9T=E_Ca~CN9jr3}HE(X`0FD!e z1tkAl-&B>3(*Q0cGQJ$OixmEkM8*$#d4#s_m{t;T0(ij8{FkYq&JsX;66Y;Ou?AVg z@}9vZm-OngG^BL#s|W&INA#=5WEh-6OR@@MC#oMnLv88Johc}`DXITr2mOa^>#y0W zBVeU-|I<+vi?ayE0_Oe&clE9~^t4bH#)P~J<^OqaUJl0*$$Ci(9n#}4DmQKog?&%^ z&-ZxEWP4Y%5p^DeITYDqPyel}sr*lY&HvRKDX_fHiUT&1%Z2XlI&p>bx{H~++lz)q z-qq#}y51`at2aSI#MA3XDM$mA4>7#~;I6;hkSPcRcIc zJ*WZ>oc;WdQn*URP88G!9MEBfOxYTn_+DV29+t(C4##dZh!sb}r>fakZ`=`(JZ&M1 znrYO6?Vqd+u9j#0S`VE(h&jP=+x9>8MXI7NzNhwcnrDRSRb$5Yt-KHS~Os|@{k;oJA^0x3zrGfT9DBx~1y%oMYwm6w&6Or^o zrf$W=j-nD<{LO2|XQA!jp?p;qo|beSKVaaQ&cFIvqUxH^GZLN!-nm!|uX14-kYL8U zg*_j!WH1###VO&x!}ECEWRC+!ojQGz9co?O!XA8ZQ6PnY4zZdv#ZX{(q@;_y5@GO6 zxuvfmA&ji8OTAvccHms;py0~)n2wsR2Ng{opg}cc8X-oVq2o#>G|dW$r_${fRlnj) zA27809jYd{Adsbj;NV@ZQseoC`3b`N=9`$9BS|JH}<$<;m6{9-~qD81Ykv0b|vJ3(>U zDgXh`?!`$uIV)eWqo?PbyAzA^sRl95Ot&tp+wmXTjR<2*h0o@TgggA34c=K z&!^9Yj7@YtW!Q2^!H94{qC|`G+YhVK57yD_81ytj*?!(QjzlgNnbGzm(2(3R3Jy}% z%<2_|HIjMt>m`Jto?gDhFU8yPEOTeRak*3O(CaW{+ODS*bQBsB8L& zmetQUnap30w1&;yEcf0bYBmBcP{JkKgN>EXbmU&ES*7oNeZk6#B_;&Ie0#+X+fB-) z!0O^qFmt_|84BjGV`bekc@Rj_PeAoq>!sz|b%ORf><^rIcbaaIwd*`)iEaVi4fM1Q z8nQ@bO&dX+5&=1T>gf+kQ3}|)=mLI;Ch6=nE-b=m!TQERC?ZPU=b|(kXPlSY@~-c@ z?z?4~A&IGJfp>A%AlPvreZHOJd$!b2xE+2fbBQzbWq?q&W9B4^Y*?jN$5WrAgb;i;v+8e4Vo1hq$PShh)YtaMxK^}%ElqG0q7J~k^CsY%8$R9VI z#zk_0<;l_=lMCrJ76z4K%RVQur~o$tXJ9M4p{rzf|vAry8-tyHER_S#PzCw2>*1U)i*ddV|NTdvrk~PikvnoqzkGbh>3h zH?$t3J%stf=#*?cK*u?r;o1lJ$@#5|%-mfF`QYAdO_n)7$~>IVO1y*n#oi%@yEL2i z&`<_Yc>nyxuM)%k&AO zC$b$!Ql=x#9!&`BhYosMixvydcfBY#NP~Q_8meVV}@thG(;6fKv>i&iar%51R@Z{dQX z!2AQoz15bdsfc@*8imh$NMw($%Wxo3^5pnbDNSyu7H*aD)g+|>k@Qs60x?AZTX+gp zhDt;9@*iE5xeO^hIBc9v22E@>d_`1D0f>1bemPt5t!d5aN&o%npB=zZW|BKbHX2cdS>r6 zgQZP7Hs;fIJUk^vW@VK=9scU_J9P8Gf>?3&Lk!M@-Xc==Kr(a013a1GL1Ml|lPWtI zm!&@IN5*(jWZ5sEF;M4z;&lb361vWHq0FwKEFi^h9D2N&?lb#~_^F{7oBE=9b*}d9 z#5)k_P)xOhmQ_d@S}sJ;_t{eF>8;)rmPP73Vtu{8Q9%JZ#`S4k(#|vkohKB#%Jiu# zZE~$?CuA_6kCQ*ZBV67P;RSG%Zlq)9xcOoU5d#xZ%;_VPW^KAJxm{lEchytGYgq42 zA0fvSEHl+a?Ay#`oU@Uo1QpJ0@*zr6DU&(nsmq*l;_jMq|9*}VXXYG=F4inUW-|F_ zyx;64m?cU@6Yf9mj@XVolusIKf4aDTW6ij4YH761<7P&ylV51W81TZ9#H%59%RW{2 zlyM}RS~-x=)bBH8Y1OP^-P$w9rkOaa@bEGoXU@u1PZ`Y#5i>y-UGB~1Q_t1Tsa~#R zX(cdBQGs=gqCIf5I+b|)PQtqgUQ@g?W8;GZ_b$~!BHD{WBmK`Ysp?okA0@lYIPBTS zj|;!Wv|4m@b1mcx>NOvvp1%X5v7ZO>l>2oMNT)#9EWc1;$pPsGENxN2Bt)4qeBe$x z))-S`nUp}8Xn_xg6TcZ=IKhNgUbiokz^3qD_*+{ADX(6=Mz$C4t{M`2=cqyh^45y| zuO%RkkAg1VLv2aRj~IZNIO0~093 zkrP#AD%v@lAk(!zp23Dq{SBnSb)pd1mF@P<{JG`ayPF(S2Fm(lif%-* zHVM+8_Xh+ITqc%wl(#~fN!himwW|e`b*wMz4fzr_?~+Sez#?JEP0^!b0(`z8X8sll z1(^!@YeFAz?rANuw&~@C4=As^;?gmyD3sSMOGQ<*c!(*u8`3auxP{4M_w8Jvv+T|VxQQ3eZ2ZKl?deG zLyt$UP^40cMNZC@!Tf9L66P)9OV=CJlf5%L9a`2}Kd^vJN#6(pFKcC4Uf~MD$EUi~ zxU-Gw&*W=WF(tsf!M&TC8p%~IX$s|aF;Ue7NScHKhIF3EUAa2TOE9hdG*jP;j#W>5)!~-+#X!K z^mtY$5I2~z8rzo&j=fX!Y0O-~y%?HZvgt_^G8BsM@JD8gXVBFnjT=Q_Y~lzPEMk~U zbRBDKj>UQsH;Y{&)#I{AG&>FNT7n2Nwm-kHlfQH05OHW{-5r%0Xb3LBIp!er`x3d; zKIu}#=Qn5D5_7dlmkSNrk?Z~!^ScU>Qt!c0WKV|h0~H^<6z%^N(xdeMUw9)o)^)wz zuiKQpT`?ED+k3VVyd(El^A0gkFSvU{hOj2chD-;&?S+BK!!;LN9R8612FZ(?1v}Ee zxY31LU%r?-*<0-isPviDJ^M8-P?Z#ko|2A21;2FKn#XEl4s8oP;qsLJ`4_D8L*^M1Uiv_2o zZwand;P2y9^^aGM9@yW7s4PlE_HHacku6@!W1^b@^yAn8t=TG#X2;}Ypl0!3#*U0Kaj29GJAP*v;aFtRK33&o{Tc?lvJ z4GYrfU3yvJN~h6QBXYy?$?C?Ykob8*zsRK0(?P9xcVyJh| z-W!5*s4XWk?y?U4AT}Xq!D>Yn{8{?8B3qI^;@<t}(xk*4 z0BEzixYUgo2ty;W*N_;)C*7T4&M6!6uPCOtv?wWE2@d+E zAd3}r7(zJq@jU)p#YB$(p2uWimpyEav*iTjUhvfqR?Zbz_V-=otw)(VwY>3jOT1sxQSkzRY{6h%cx9(kEJv8_87ZX)B!0A5x=s$Gq2QkV^yt+x>^{3oA4(G{;^U(U1Q#}JKKJ7OUE{O8 zR{tk#-Wf)=ka09&LZQK7&14*rlb?<)LpDXqqK7~q-&p?k992;Pz`euo@Jf^PM|mqn zjw@9!o`0bNe6Qk>72~W<=gPs$LC+cA_QfI9+T{~lLMoKHOMJZXU|OXmxS(4!-Ta*`>a(TO&uPYzrByn=9iPvfoZR|kPtiP=Rodr08VKo;lOYeDNpvP==#su9X zUBLD&qSpQNa~`r6uL(Ow+oHaCOEXJYKgT>h=mu##?Zp#2SXLPUh?GLh>Bd$vK-^GF zN}T6zxGkCVUGW`dk1${#u4aYXKi#0$-H-NQ01(bok2 z#bb{Ef3SCovFGHWE`7xWg|_v}<|Nyzk^0#Ps{L8Q(gC3&_C`)J(ilWBjsbSvO6Xspzx%@Y-IH7q2*C5S;QI-&^)sc)@OZtSc*TVG35)G@c=skRtB^HAP&c}aqA_mpEo7& z9cljQnB3_A$zbBS4X1)nG*(5HYWQ zrFGOgwg%=b!2!Y)9E)xnI~i>ikb41#>mRI3W@I{PK~`im7`<4`~p)-VL(_7KIZulo-rGx{$jvo)g$jFKPE z7U~y%A7Uw~6gyH6i)n;9Z4KG2CK5cK$xW_IjjSfYeusgVV`r8ixin81bVrYKo{uM& z-2~PNwvqIBpfJCTGOW*L2?{w%*EZ$Bs_6dd)9Wcxj2znE7`13H@xc5Y!KxYaOsp_Sa=OXCpsE^SyD9fKrx8DgPMUVD@LA6MT7EKh#8Grh91i0-#ygF+`? zjI3FMp%_p+<`39A@Au>NZ}&LkkqY{z_j#I9 z{>;k~2}FLV;vMP%9Gq9n-JM$Z%`Kv+0ACK42pjQB{f$CL@=9!P8yQyf3aq@pc0@B$~9$FO{8pP5{?@ z^`5xebFzE2 z(|X^d$4FD_%CfBz#iB?7ZZYSP+W!7?y2#CW+v;N%1;bL*13`M}d{ADuuP8H*c9 z62$zKs5RexdCz&c0Rla3;5Og8BhQ9*BasiVzjQzTbId3;V5;|-61R3JzXN|)2^Jw` zy*>9a_IWyc1LUU)i0&_ba`1PZ=g8_+x>Y!-$v3JPWlR^E+=zAdNF;DIkZh zkBWEXf1dKdkbVID`WfpmDM5I*)kZi1U^=VsoA80A@FW8C;Sw3%N;BhkXTd&o`e02cAjz%Rp99znQ6eJsGS0n*3=14egi z-~oN46d%SN9dRMOI{!m^txYC0eUR=qePebaqmuupnWK0+al-~+?02eKCmnJs>0dvi zDMg+1S>Iu`YcafyM$P!HtmF;!*0TqsgLkJSVE|jAH^p*4?VBNd8>WfTZX9EE=XCAX zCmj3;S^Yj^yyUkuG%_IDRS3d_Li{QNo-++}%*)V+mms`lEhwyjS#ijqvu%_dC{7$h zdPG-8=+a^7K0^k+U?O}u9G>OuTHIo#UHL!ZZ1v-XFK%TbClg?G5V*N)uVaFzg!i{k zBQVqaz_4~=te`eq8O>~=Y^W#@;d1~pOcs~~!HokoO9bnDSXzU&oq>vyji%^gbyK2t zUP+UmJdT^pn&}wD{hV(dKJ!fKiSAZo(7j#)l^y>Jz2hKa8qc)3l`R{i<*MxMyq{gD zyRIGw^aJ<7MrYT37lOCz8F2#OXMNNbJBpN7OI4BT(l`KO8RJ_iIW_(TpQHJOYqPdR zzT$S3dkw4R5lR|Q{#%fZ?eMLNfFv>X&fHmh zj}S!67VM$0tW^0x`X1~s;2XIUsdfanLFxvX`@O0;@+0wz+2;!=tC-Z?o8yCg`b0RL zNtvzq7ewWDNY1r3zaggoC%svF_C)uS3LX^-(8`m>)4urM+M?dpzSvpbT)QTNe*WwG zj`Op1R>RXr33?zByBOIBx$psbtkA{CH_aQ$dp(kV8^{{try@ev62H*0Mx2PRDB_#N zZ^6~Wbb4@kr>$*ESpR`69o#?PE4CqNnUoIpW!m(rpf{6Up|`TxOul*L3`G-Gj`>HG zKe)xGq!)adg@+^87xdWC{5q(%>XMvN;%|{bTx?V71-3i#pjgdh zTfx#Vzv882{0hx8PO0VzHOo%@yIe^nyYvW}Q8R@NlWY}95bZ?{=rQP?v_3z4>x_lQ zuX7P18lKf@!tjb;Q=i4;uiY+6Uldb!s|J92n*J_9DG5~)a!$PDswc3TDm@KSEQaCs z72GeRvk&_&ycfCzypHGh-TZUKr+yxXDM8Jv* zl>Ai5<@br4z~{)npEW){(#2AVo-BTc0U`Fq1akjhNggml@vFjq;2*#tJcTg{|GVEL z*~baMQBt~8!#oz)K`PhKslsQF@Ah5_MW;#FI7$D-xscZl*F^?jK`j&y30^*hD(Zh^ z1l}2OBHzfz#N?KRUw5v)DfR1;oUraU%l^l?Akjtg*Z(qu?N;|3U4UMENfx12+dxo=;n=NSiKn*vvhvi32m!2MHZJuDfA8uC7BCR}>mx97H^f~B5Y63$ z4qk%jT%~7BaJ6hl0IY$UmkFWzM|T%%Mz{-n49WZ^;yncqK0vJgEI9S-Lsw|NgHW*x zw7nVEIV_;!ovWb$6>qNw_g^6o$nEs*At*kM*(eXnCS+haQF&R&TC*Dtq}4u;}n?J{5v zCLpvKdW4nDuRJUmb>{81V}k!X@D&*WS2iiJ<6a2c*e_gg$wEOo)DSg5L@&J=(g!@LFi#KJ% zuNTNF%{~W7%50C>ly}Xdb(Uon{TGf`h+gJu9|ZMCTOt@D>T2%c;QO8tvp$D*J$KLNELbR$-tH<440 zFV8e$_{pxa7N+|*zGh|%8h|es4`uS2w)$qBXSB!C4ZnL>#6o2>KG42S5;M$&J6^zqx@xvgWgzBKTjuE<~b+fR4uMbc+aS(~A~~I;^l=Tr#L# zt(iu$`khl%K5vGyJuyCo2{5WTXTkK#APRGX&S6S4oa*YDl*f!9q_N-vxBI-;4W#Sj)3YlUc00~5#Q53sZ=T&nl4H6R#qE~55G&$?uSa=zx`A?X zo__u!zsPEGo?P6^li3ICN(Tq!UFQCi6h5)j41^DqBHmw+>?^fI$MWjGQ|I*uAlT+VvJg8+2ddR zze?Jt2);AHhiGBZ?+WYu6Zmhx2bqlfx!$tPsZj#8Af3|eLlmxLma`uq@Oy&m_Cyj95vJq2h=N|6y0DmS zZu^46Xvfr++RSoU)_2Jv!DATsP-)DT8>`?T*lIsYH1>>R#%ga#+{p%Rux@3#_UM3| zHr(LGNzmR6*e^gNF4+--(Dg+CKbMME>-Av-5@bH$R=oo?Z$Y7a{mLAGKPI@ldIC`4 zQa)==bn_VN-G2)*wP*|Abd{5^Y zvs>NDk#w5=#(Yk1^`pfuSJK&5&|Po5&D1u#ZH~y4teZ{dz-wq5Pg-E_+12P4fz9n(o~3v#;o0fRF|r z6o84p&QR_Wrjob+;RTBJK{uN0WJI2IE`?i zg$r8jO)!r&umFO-`&+O?CRe+zMz1mTyntWQ2K+=7{>yC$6cL}F|CcTH0*jqcQCwvy zN_2RM^i5^m#d7evrM9bxkL!ud{ zx;2=>g*D}2LO=KI)?4r>{6OZB{Bm4n9@c|nSdr;oZ|A5w{AP!SYhY}gX*lLgVYBxo zAl4n0vpfU&IFek!)(b9oq@N{pt8_=;euUWEypYBcr zVh$$)E)31{9$hR@8j!WZe7#*F?7Ag!8l{b+wXK4i*Z+e8|J5F*_k+6tUe^^KLGNH& zJ)@C z&^SgRKoQ*hr^{$gjY%a--@S5?U?`4S(A#LQq*dl0Db-Iaz|8sT+!-0fCHee0{NBSE z=Jhq=;9g`}ojvX?-{PeJFwsm{WE|eJKc6uA?OEN8mSSpK_XP?Cza2;bTU5bhVsYFH zm}!Vw34AW%vqvg{mu2)m49|VPG*)R8ZUt!Rb;M~zEl=78(~$q#2&hi0`?%{H^UO@Y zU<2C_5YR!{zfvWV-?$#|uPfy={q?tlJe&UU|B8qC|IX)3pZxJbLp4n0l+VcDiKUK{ zA4lmQoB8W`-u>k(BJ78#x9{`1J;U?_^6!|Jh{3J3!<5UAtypEXxjUS5KgH-uqCmmy zJ1vBNz|DVgIb>X8%PM+hiP;yz!JOZ~AyCBjFNpy}>K{V6@+O%Wjk+U9I7)xL?|+ft z{{|6#3ja9wAKSWo1_q8&_&?~czlCW4FJDWnRVVc5BHO=N7#Qyj_`pM(-V5LnDld~pUvB7UnfZjDw@>qCuJio z9LYoo4opA~HGTIVdpn!ZrzD~3^KvP2${Zu}^9ay>?Yn~h7iJ?%QjK7gmQNB&k1v^euJzx#I-NImzL;ltF z{u`JpCx94OYO{*kfHq39G-=kbpB{M&^2(K>TAi)MgX$afzY&AoT`JmZiiTvzD0-~6 ziemf3P0_BUKruB@7uKI@g2g}rfj~A`ocEI#NIA_9=UOXqYnE+Oo4a^RajzK8Y%X@bj!V2 z0A&I*;HL(t;8q_d5{~QYEW;vG+^5*xF~ea+MSba0J_iY2xIH_Q*gh8>$bzzGF5)A$ zY=WMuPt*le&DQ==2@1y=ZM{pB_#xU&^BdR2zkkFqE!5vSCYQJi7) z2~|+`!vNe^N`BGYg~G8R11NwX|I;#Zt}4SU7zTBYI=s_0%e7~oP6#{0^`z|xVf6x- z4oqCD*MB1Xdb)K73hYK?9Ghw^lpc{UGGWj?s;gUVNEauwcBp;bcB1kK+&`wk9Tm{a6o`ZA2cAS zO~@kL1DBr8zxB?DBHkt~TXK%jg}DTIthJZn7ee^QzYKo4mqyI}>> zD1;GB`0>389&Av_jfCmPk|%6&BEk(ejh-D?z@!G`ix28`;ZW0B7}jey z=7lvUEt0_jRNhC{(`fT9?N*|FRnuXjS6zc4Za^yI%r2wz^VI?ixK{Ac{ixs!JyVF@ zhq?qCs(CjVoBxzVl{33i96sKct2VF^8opO9#tuUL`pSkQvsNvBEi_BjVQui90MgbK zE<1Lkj?tuT%v6T(P)xVUYK<{`t8OY>9PKWGA-y;C{bKl~a(ryi6rf`&H|@-Xh}U$@ zR6!4J|LQ(>Lq~7mZxo+a=iG!AjTq)mDlCLT*i`0rx^L!11-Cw7y3VdBuE{hA>578{?DGcZO-7=k{se45~ZDZSEQ*R03bqZRFJPhC|+W z?jT{OE$OYPKC?Yy6J>h)fwCIUj*Rm=xdSm!-Jmg|hcj&l3LrqR2gazMWQQJ}^!qjT zT>U&ML4{WC=FEW6(4m$j@~CMtXYH3G2o`FpUGzUNtRA^@ihukC1P>#pT}n{<)Ol5O zu1e4xDcUn~2q515m3<3u1Rzk(M#^Rl-|&3ONS*Aj0iQoyWQ^QJ9lKPoS$?T^-X;dAXPjWK3TuhScD#ear5VdK(+%&ip^svD$PUHwUQg0&sw(!1$M9c z?a6d6^NN?^9guZE*^BsiKos?ILj9Da*X`l7@Zq7xE@O}B=-s_yhw1wzExwmaWq_!- zu7~?N?YdzC0Tk+l7FvbipXR9BTdCfG0?a>!YV2^x5HK%&tF-|iBCbG1Y6ITQiDELo`>=nl=Juz=z^-9d=b$t!I>D&B&Ack*ZfhJO*7fb zC?Q4kfU1QNzMvoIY?zSe`G909g7<^~Np6KeQt|gGX+nP21M!DvF8X0TmVV_I)nu$n z{=ms9KE5#X%Bp2pwSh*x^bYCVHpKI(ZQ&#YHMT&V<5LfMKZ#z4SIo_8h(S`#xer zy*D0`zWo_YzeKG#63%4MO@XUBM&+;ll$#z;viuX%GvJLbey*NW0U#vtbQoW@&{rd7%I*revJ4LpPGPcN5mItvqR-Zv4*{ds+!3A!(Oz4Z8D zx9*WgsYg$n=7MB4-=T%E7Zn}R4Ikp+x9nvk%J=q@ganuIK9|FToMx&6+U$HRBQC*m zEf=Px^V{l%D6~5K7u_GZus354V9edO*Kg%;*Ubfdn z`fs#a?1RLHl7GX&RWpyLe~;s`2h3QX8b?m3HB*)g58WI$f%9kQh0T?N{8)lv_8axn zE;&6@ooRYgY{F<2e(?xn5{3&8mo;BJG}e(cmOl6PEvo7FniYyZu-K%y zD4z<=m{eILe*$AS%m1UqL4dXPU8~jV!AGoY&%I865eB{Li`3FVc)e_V5e?fhUfn?* z9Ht70{L*&%_n*I#ToZg*SYrp@Kgs5es2^3=8}8k-^OnQ3 z9?#qJnBM|0Y3vX20?y|hB6)pT1OCysq)qXdYWv5nPkh>iANU-$vuV-JhCwira7n#5{%*s%RoI(#Z z@0UBTrX~H{4yQG0M{0*D`>VWjjjO|*GnZQN1<=)Fi~H(}va{+FsR@ruMEFdmG$8Ph zioCexxEp`>r-k&VtS)PYrmbR@HHQ!W46D9$J_xL_y+2yAM9W?gp$W)kJ~dncrb{re zMqlVz<3S16K?bSN;N3h>wVL=`<;iygCY$dVvnMQGak~kU9d)np6<@j0A;ad8wKr3{ zqNkDW+_P2DbxX5U>s!|5S`ShplHaIsqr>C)a2S*A(~>n`IM*i(UMA)CV}qZ%x_i=^ zP|VQuL_dnVm${RlIUp`+lLQp|=u0gR|8yg8HNU=D`X+^)pI4s6xO5t4Tp2)M{ z95-v*E($F3hF%VH%(qmD_W(YZ`)c==CLgVZx_ztvNG>~TJa zGdIX-NxPD9M#+u1ip_oYx{uSHada3FGjQ?h?dkRYCF zTebHyurHWdaco?^s8nI>o%$X&TYu>`&wiCY$=t``(j)oUcS$`Ip*s3~raqq;LY1pD z`YH74nWikPxp)~-1e^p_xXj^`RQcZ%dIwe5%#TsAAfcfqXITC2TfTZ_C#}l?(`ydIM023kicshHuw@Id}`Z3kc*c(YyX4 z2R`&d+1*f*5#t!ZB`@`>?IkqtSc{h~+nFce^&p0#>VIvJj`1a>CySca+tWc~>LM54 z{DsQ$PL(P@@qFan_;8|piK$Pl$afps`UL_84Ppku&90mJ2I_4smsWXl@!&Z1iwWr! zx3wRBW_`<>FIzgR9-(GysAGgYN93>!|k8cV!ltX;4VGWnhl{{`ehg}?iq zHbL5r0V@2^Si`haSWFnTIkROYFQ^F>2LrK*)YY2?PHsb4SpLjx?D1I)`YWJHe9G(I z-zZDzm){A}d8)frWC!&rE`m4ooR2==k_Z0!t2lU-=0!VVxpr)(G~IKC^`A+iMaj=z zKM@|37>j$d5*%0ET>GT?!kEb}O`zy{uKHqvyHkTMf9ZoVKr(Ksc`4pv)knk^NhOSG z;WkWx(ZO_4fh>=#FBih7!zC0KVWlA+nLE4vL?_jd{WnvpG^;%tA0J3@84^@N<~O|z z)x>{%V4dLI@h>W+awJq^p(}9PWmG<;2Y_Uyt)S{?Nq)3;A`fep(Z5C`@FP19IUM!3 zG6VP3v$&*usQkp3WR+@x3u8dyGZm_j)DsN2;sm91?*v>z{`M~;EK#XJ%{kFnG0PN>Tu?s zv+k5=!#!MW(K#uhIi7@~UBznUr-lkS$GFZoORD;Ly%mU|SkK~$7;$Z<9nBJ0VdXiI z?>k<2U8jbWfXTI?Zr@{zggEn9yQGRmKcVAgp9p|DZmoV9tpUdTAAsrQFm1C6Q7( zLts2NlVm1OsW5=$kwUawfe$Lk%4wwbQ&_A|LOJ*c?OFwa;U66ko0Z@9o=v|Zs!t~J zBRLxc4f^5ZjO>%97Ile^T_8kTZ`>ZcYIO}U8vKx(JZ1L{PU4f`x9z??zDG?{i#?_} zs2vrS(pJfyVeRUZP$Jl5<1e2rXOnxG_=tCHz`mw0kE9=p=(*O+nw*-((w9rvl%awo zH#_|LChf8mUmwbW{uM7{6==KpXcz}~Ga2R2Wu2OHQ^D{j8em^{f2J9>zCu==zO~U1 zl}P>~<5*a`cRN1%qI|)umqF!u_FPFY#Yf$K6>*8CE5Fdd1tZd<-*GcGO;^V+-++}$ zR*tSz0Z}`#wdXjq3ZPCF(XT0pk)>S+dkIyw2C^norNBwNDuWa(wh`^|?@vb-L$j|r z=nNuUK{n9rqnGd5QHJ#XEp@!ZyXn#lo>QOu!PXkxURE;DtgO~A{w{!W=zq5Hdd!3| zb#@sb&|%#}W5I=R%=hx)9-52c5vje#AzyL)fvDm>=pd7l>cHfCV5q$0EANmN;@mR? zFG-&Pq^*08^X~}`mr5XCsyG*8*7gI=uOlgj>q~vsyD~$yx1C-{;D5>`aRK>oN2+dK z87DFe;9=X2CnCW8zf2!P=5DAB_9SaFO-h{v%P>LFjKDXV#ZggKDx~+Cm$mX>T>+tb zvQCh#!-#7D4F_~40(IcODy$MHu+Sxd(b%(}g4A^F`m&`J5L$HhUIYnve|ZAl(6M3) z^gSE>5*j-&T36~-3r&8@?wM6SFVg^F>*B|S7np*tiCqm^ulR02yI*@|DZPdkJM+LU z5X@hI#mVFwjg|(E`^S!4I>5MKtS;9@7(W}RMU~e0+uS(A(@Dj)-&(IIB%Sl=Ne=(J zT8kjoV*`G=o(1$}ChGl2B2!eOM-rc^(Lk9QC`SP$XV8Rd)&%dd(pd6ujd^f{nDuA5 zZtM0#j#9POyinj?OXoCy)>^T2%Dxe#MVwg;ev+n6mB%)QHjZ^WC-#agk1@3CORz=@ z@|p8qIJ?DJLyf(d2{jXU(iHq%n~}-^OJV-aV_$C zOJ8kXB=&0ZiER3w@nkDnj${$1_fRzFz&vngOdMAHJEk{}O1J!Yfr<=YKMYHm^Q-2c z0{~VgTgvyNc}zIAHi>{t>cWNX-&XvWV4i(d&eyEI-KyA9990x*BUn_+_id7Dc*?Ol zPCCbvap-3VU{e6v+cAz|e=Mk+bWO(e#f)=Mpo7i3NSq@dT&9c##B^6vUVp0O^R*6tWbLe$%=-N7(Wkr zL7;s}1upoXg)UHl!3V}LR%kN!nThM>IXUKtXosf+*(jL*Js+DwDQ}la2gFYzA8;su zw)$2aEl3Y#Q%GO7r~2E=GwN4W?yynTi{Q10&{j lad}}ScA(hq{}b!j0~yqEDNosvTnz$V;_sxPrNWzdb`b3MUt8Tiiw>(?4~5cH@6_75(O>CqDiqJX5tgx@-+?9O|cYF#u6-faljTjP-Mpgnrs z`b;46Q;&npZxbv&OQ~4o{aE8FTkUVD7zS=(llvl!8*KtLYPOb({kL?Qn+CPJe6!1g zORIX8jU2PG7%bbv_bigI)0+4{@RE%B$b(rX6_Byr5Qp==y-W> zA|UxOcu4u+V(0cQ?NV^@4|-(%0mo7$#o@f`A}TiQ$N0o+@&-9%g#oTibLxgA2=-%& z7bywBd83&%9(ezKL-ptqF^V1i4PDWN4i@ZFiujQV5F8Jhh#2^(YUS63>o7-1(>zF~ zpaMTxvSg*uaV!*DNtpWge%Y=K@Te_j>af2Sd-7|QTVG0>Q9^Hcay-0Iq~o{OWa-Sl z^d^g@AV%?)W@Ko`5cAzTY`z(H-dgVLxkzdrJpT(X>Y%~o``RkCphJ?Hc8zDP6~?$L znD}g0Di}=)c!>M-7Ot$YyPsOW@`OVimJ~Mmo#o)fA9JP8mGw>h$KYq?tM?ypk;Y$h zRgqL>ti8kAzHi<^mG4%Xsl81mZ47sSye~hA`^C{S8^b^1r6I9>T}P&9eX3pd^}q)< z2i0102%8(O1Px9p@#@Hj=wW=Gch~!YFD(;I2vjG;z9VZ>B(vW2wr~akDs~8V#8ek& z%|B>wXMB58>1t&ZKO($IfpEOadQheVna74Mcgr=aNQ&B)v<73b@!b4P{UUQ05tSwQ zKCHpBG_7_}2Ko&mqg;1UVctK4Ex6)f1v1t$1I48k)z@dq>@`P268gB_5gX4mSjX{0 z+AngDr^Z?~J+&rl`b0>gZ-|lg@D*8&n;d1wW^)!VM+Y~{)vwuHjqJnxmgbB#1_F+2Kwm6OxTc0*0(}t+&!3l$r<-Li*_x;~$Y#57m$z1aci#45(?-=uLgW*R0Ij4PPnvU3bH`kzIgquwAi;&lf6y4qGOzT&6A|BA>>l2+=J>jB%~f} za9l_Tjukk5uV>rLsNwWWXHjvdsL?*G*xq*ZnaN#MJ>wF7O%8|dMJkG3FkCV8=%6}i z%gaDM88-Ntgb*b^-P(tnUQ!%QrQhp?(JGlfw5a81Jv3WVUttGM);R{xm zS&M7gwZq+*4h+H})pM1y-iq0`wboowPs{&qwK|vS!^G_(Q`APhS67W~D=K+!H#l_y zf_oBR=PyM=j$Bs-9*lCyXl6Q6b)1ryc1Gm54dhvtKY+LH$yBH%PS<6jI+mk1ohBC# zH)_|hqHdU?B20?n9TkR!A8HS;_lN5{I*g{2YriS7(F~6~g=* zrgy=AhQm)$HulR=w=D|a=xN1G-cMUB#~#8#12=n+H_M_?U-@Q3KuI|ka#E`4w3TFc z(88B0Im}Q>-lS0lur`@boG9a;Dd_Zp*`UI|xhTSWAGwpwuO{L&7Qe!OsM~^S4idqp zWs#_Qa2vT2p#&`Oxyl+Ogtl|WMNT646vv1oMCuq3f5nT)KF3EHAAkBf&l1;rEowDA zoZ43#S&f)MMro8ILyEY?SBtLh2cwUwYeX9+MeeMS) zpL8*+*esd;QI&dncvd~IX8B#$3;{SKRGNY=NKuEk<0963>EexFiuSvd(I=}9Y~8a; zqr9)$)I=DKh!!HEvF6<3(Oc17u$uVtL}pBUx5ACsrd zZ8XStZyRpk!4}SR&-M{T=)HD_&%I9bOvL%D3x4L)*g8VeVH91Q81g^lHB6a5h$8dR z^A|DyNOcYSVO)?skpF>qR;|MGI|@wgi>i8>!mLOUhKnyGc7Kl#a&lQ5I+2rz9>dGh zOhX#K43hRu))cT`h7ctzO5WWKxh@JxsaGEGDSf!@w$Puh=uMEAm|p7n5m|uNcurMN za(77Tl@TfH5Gg)1In|DirMcMEC0m^|R!)fU!Fz!?_ERqv&P`fB$y&60G7;Gy>s z6FDTfh)a`Z1T1OxCa(m++IZ5{9FHOi{ay5VTpXby+8?o%n&gzPi$3Ccl?6q#a}tsJ zbw1lFo$W(1)ST$^wR_QO=7HGOsMWVc#+qioGHIr!X?-Y1kILj){{1b>`g~0S@*NE| zNP~eSIJYElhms&{JEE?N4Sz19B&8ol^rwUz$UNjqU&u$O#Hl8mLv z@GjV+U%*Y1N2-dnKH5cEh5dljBS{hDx0H!|{eBJ0&PE@Jx|p`$+-KXJGY_j7EG^gI z&1pQUUTZMU4T12X^>n-ylp#er(jBvKl~8y&w!jKuH03N7+6tzfoIZoC3}bi^Cq+fU zlqZej3#)Ri(HRU6JUO?4A|0bG;pgm_#SZ2pcR1rQ%ydC7sRO~#+6jGsH;;~b~7$+21T&QOsRvj>Czi>3!iq=Bp7qCa#sF?tKUq_V^vu`NSW zySv4Ty|I}~Ro2J-BC7bOl`5%@h!X73NWf=KZ!rR&$)UGZyNE^bWxZdk&r|PmUa89p zqEeJ>EoWb#-!eD|O;VuQjK`su#pTqf;Cqjha`asHm7Ctm`sWdsg|1IUY@94Ret+mJ zjf|$w#dB0<*+qv2LajwNQxFT`I%1=D#)~_+_r6FH?A0Hq*jRl6DR59HE4w zGATNj_A-dCT~!U7z2|aY-7jErqqWxw*+g2idN~>okr96xsaSlp!IEsl4~mM~Mnd|!XB&)L32SmW;u}!`@>>Rk-8j-?9*FcthQ#Bz` z4G=~zaSB(9c?C^z0h~|i3$M32wGY2N9yn+%HNCR*Q`et=4(A8UADdFK{NAyNQ+B_p z33WQ6iiDk|TZD>T^ak(4P3Be&4{1k!QSwh4*<&}co}?Ilrk7yN!2kQfpve-W{zK%Z zNvyD>sbJ@V0kv=D<>k6OUUTpk`vUV5&fH1%2kdJ4}N z`?LRU$fHd-nvL7VVAw#uB}fwsQC;{jNza}B>Qju8%_awFjoJMa0ZPO1J|4A{O^{kt zM~qK@R1W>cyJ_@=$WZ_s<$A1*IogOvJ716vR7Hv^SV>Q-KOfr+1SPQV)5N|j9^Uso zLU|Z!73++1U{`vM_MtgeV8(h!QfQ#&bR;+%m(h3Y1}8U&1s>rA@yj#?!DhQJL89E_ zVcFzIo#LnRc6KPR70!}Wk;;2V>)bqy#W`J9HdymOUTZza3&n)vTO!Ik+wL6azw?;v ztVhJ!<1-@ZC3KhaGh)BS_9zR2`;MATzG&9s{$cO-AGBYXr1gfp4`d!L-)yxL#mlYt z@1M(WeBXLUS5E&X%oDaELG)HT_(I-u7squ^Sl^Ac${G|t&j0)A5z)#LI2uBv60b&A z#qOl|dAS;_4VKNnbKEhVul>D~tYoukc4DRmVTG|!9Xdp_#2X&D%r;MtfP`;X3u^Jm zwLX2Rmd#((_+EGe2bTW$3nux!dHM#_8l)4#@XWR+oQSddi!Zekr>yy7h*b+1#a_=c z2MMZo{8r1q6^ngp_jV2BYjc_>+jj?eJ$4G~Lw_5YV=y=|XSB_NK{BBkM>N9g0@VJ4 z!3|w^vbt;v>!$!9N{Iid#j_8y?(l_8^BdNVCc@}h!&ShiUgl)3<=y>VO4_G(%mT2p zPo-(K!v$|Ji^%c*m$h|{YcKqw+K7si#?Uvv&W&?4?6h`Aoc2BjM{It<0fY@;co`0WeWD(gcqtP&0QrIH`^i=?q729XEz3%jXdCEU z3Y;XdQ>Tub&Ou2JrD$oC#AE>5n#lqIe8X@NNB>)GzdQ41VGye+s@P-D1^K!~#^MXj z{h)bP)<4GAdJc5m>DKoNVE9 zXa%TD{|#QHCk^&QZm^Ija~dh2&mJGaser*b&`%(E#e|;|D7*wnIn$+i9u@COo6ja< zRRbLW&sI0N!OJF9{BJ0EhF~xJjQi<+F?GZm?rZXUUkVI(RKd(*IzmEUtk~BwUjmR~A$$L|**IUW{Dv^; z^+PBL8DoAJArRC^90BOvWq?52yVA19XLo8Z-3Hu~riT4I1%q4QHZvL6#F=??lCYUz z$BeNt1|_PC9QE6@Ng5T-lSkKhu*mG!wFD?TW!jKTm%Y#CAhNn(FE8;-D}(zvc{=OvwfE7XKa{t`&u-!o!Yh-;N?{XB zL!?dhjkBDdA7NdM`Yerlo%>dsH-YT!BmTw*C3zEbTu;Y2-ZgP_Zj5r>F27O~v+Poe zZ`^Dm^mtqlI~Bk2mi}TjszxKsOnskR6VCb?4^|XcOTNOf{if~Z;8SH`&f;#H@}vYNN~syuJ72jsf+=`^x5{R z;&(@T-50s~4qW5V(kW~svYrYHQ&WiQnW6s75uEEM>U-vw2N?^uYkbbYPMoTc&)H5M z#hUmj8uXSS5PKG+0E(wbbu|Z_Kv|h?Mjz))opkTK&&ZdR7U9q^nMNi{}#!U2g*~k{Sk`{<2Cr z$IcVp?s;%0+EQlJ0DoVz>O}R4gLK*!H$3mFIt=^vEXlD|PI#P9_`Ou9TzqrOM5_I=^ydJu15kcoDriAq?=FvzQkTT za9ec0iR{ip{)ZE|s=5sk|6P(jz@UH~MzOc-|9ufTCI7F!Rk290K1^;}I5n~ByC8vs zwIozoG5UXdlm8VLfN(sDyeeBwEdJ)AkJ6?b?R%3CE^4gi!}heeshwYxP0qr`0GJ&5 zXWsTHrrf?l7AGSre1tp){xuntTm+vE+J;5H_lnS0qP>)_%0o;}B5=&OX#Cir;?QUn z3Cjta!By+nQMWfbU5h)q1zHwP;>-L~fsLF)t=#NkzqEfk^K%ijqii%ff9yW5Qj9-* z+kwi+mo{CWyF*aP`uDrLdzCHrGIs1SwM&2KV~f9zQ)1gUlk??9I0UrHBDyhrN@;zV z;fyab#B<$+{bz51yDVs>e(veto%w7!0EY;fJMxB%$SGdB3#=ZToAZ72I3`%VsVi9N zByc38G+Ig_(8+U?_Kf_yF$x8o3a#dr0_L*Z$7xz*F29a`s;-{Z6=Z>FWs_yE`;#(Q zR&IHe&ThWHnH(s@ukW6yJG%J2r4PNs7OzS@ZPC>CuWn1SEfsX!=WLq7=NQ{k?mf^4 zf6H^&7fM+FJZjXWx+%tsg=tad_~xw%-l~JKs<6e0;_4jf&~Nu0)L?pzjO#Y zd_@vM>G;>}VKhh{o1dUB9i1iYamEX7PYF?sd$6`t_TkXHD?qGvaPljv%}lsVrPTS?|K z)5~(ZgMw_NPwfQDTe^>EMH0R579SC3u8jZKlejEXn;lu@|5UqpM7W1QeiNQ`K5^Zr zwxh5U-poyA(bXBrl6QLWgh+Snxcl$Q=e&)9M!^S|?sj-?JiVml0%|f6S%M4Qi?Y4hnxouCVM*1N=6--gwllx`dmNEO zC{wm&>3}tR%{f`u{XS{dRPf4-KCkIG?$x&K`K>39o%h z_Ly4r7KV=$FK6>0`_hv`KR=}$C-h2T_;KM5`TxB25!&0v)4U+aid>agD{+Dl&%$+H69lrmOo4`Q)=K+O32BRtgt1_2bvl$0gfB>-CYc9>tG?Ws`KDQaZoz1|icK z@xCT&)$i`k!jX50cRCSgm`cwYj~RBNT{(>AQE0$fC?1fMi{#|OZ>ktG7Yn_E1TSy; ze`%eNKpy#8_mS>}_}aCpr-an_PJmbM4cKuDi8Bb@&eFh3Nf(5bW#U zGC#3JQ>`c&gP8g%{lE*u^P?;)!f8$btEWBhS~a4RC^hU{<*;VT3hg!B zTFfj(!773&zxzS5)%{WFS-qz}-6x$H=a}qTj#0olO~H>Nxa=bB2{I zvIZjWnhd-(O);_9WS?Yg*})D94RLMv?gui7Wb`ZO#1SbEz-kcI5lIo{4|CiungcGk z(Q49UPp~P2XnD1d;pr~kWbED3_XenQic2%kjCTS6wI0Pe;ho~qOg@RmRE6}gHQC2t zUKwp?lr?z-T~uTC%DpM}95k(mSAr@K^geQe5KuTko_iH|HjI5~$FW92TZ0OfS!#Kl zQa{hRIG8i$Y?I8oA4G2Sx=)YeY(M}jTg^$3Ikj7Y0MY9Srs#p**YQ{3UeG`%R$53x zO>iM2JoybJ2$klA*z(fDw}>Folmk5VtI6Sox$ZBYg^T)1U9ZhH2=)SCD#Pvo%)_HRrxN(3iORmzo}fe zz{2L=LPMjlQ4#+=Nb_>R5+#H6^AB4FU1%7eWBdnZ;`zwAqgGd3<s~5^!CD5M;?xbt+R0NtEArfMVqxWB!%do%Y8P3c_!@YAz`h} z8s*fS)Nfw%Rki3GI41xe-arMU7W=rYZ& zLv*ywLW92Xpv|C&T}g9EkXHC(X39R-Nvp+D%0UDu?YJrobP0sU&qIcuuv_uqC5IAu zB$TUccZQFA73rE7?6nUE>DKsza)>Jt;FoEJI*0H0ZrkHqzZ$#P=`-L00bAUfF`ze* z{_7VsObTD`qMJ`NTvR_zPG1mxe!=z&B)m1>;2?S@q&);2HI-MjhI{}kzcGnRAsI=XTd{QX0>UptE7+Hh)vB3!dD@S>W|uIv$sf6wWiq;7zsI zULZt^EH)wji-|p5E$lkdsIS3%S8(*c@>G!oWEgE>P6~WQzq}u~3uyxO4YRsc1ITJ) zwheNa6x8i74CgN6KwvWVHAaJ}2qYf%0NN|`Myyd)vw^FWIDq12d;p`!pe(#9(yF!LyZjLUU#C*akyc3n%oGatcn5w)y0?l~>p zcd?Ew$9HtW*U=L++Zyc8Mr6cJdH8?<;9EgvWox;4aR;aq3l?<3eaepjyEv;*HFCv(C(70a}j&gHH?Qzm3=4Pd!H&b%h znhdw=Y{K-%5aX%X>8082b5z>gti3W)Io9dQnz%mk*Q(gkvYjmh<6e=CYG*2CG2C4C zd+`*gLe`8Xh>wKGZKPzQZSTvRvj=_n&;Xr_3U9qmn5PR^WDJWLl&@>fPml*)C>8n- zBd;p(eF-1OBX z27rUQ?)oTmrQ9NqpMg}&i8iQKqaZ882RhhQ;y$$so6-pKilFEqGFGri2`MA@awZ8XsmR5~f;PAJk)YFa`{FAT+ljijC;BzN zE$IMwIqY8A_%>fl4gd`38G>Z0nU|XvUg&6E?B3i04Cq520;;{?C8%zWQ_ClZi?XH5 z0Dn7%*AYvumYe)pE4@&EA|e)<+9AKzGDZXnaD1PKWhBeIp>-Rh^^B`tESuT z=E)m67@iE;9N}{9ZCI$^8W=~)o_q!Wm;DioW%_GE0Rcwzf9gddKy9z^TF;AMS+CaZDj$#L3FJ9u;CM%Ss;gJw|ykOSilWeA`Flo&wg8wSUJIlm|AhQ-h8XCMF- z0B@Q0@t2&v2MIv2$BxZj2W@%S3#p<8U7iU@{y_z}0&i0fms{LCG3I{$v9c4`19;A6 ziC?fl{?dB089r*mz`_-@k#DudN#TOU=)T-x;8o80IuD&K1JH}=z8hFjP^PM$SpWk& zM}`}^;5Wq=&)|Fhyx;?r1}G#a_5QR}S=u1^V9bRrfiD_Qf6(To<&$ehG#lwjmwoF* z`48}o?^RKim=V^|WuLV#CC#Ah)5BZq{knz6*y$lxU@mCYTjeh#QoIz-rfJ(({Bh{KrS5fnu9eQ8O+~#2b5+Fkg=NIo}pUr?5+w*}LKN&S#wecH> zo)7kOJ?j-*|5TF!<7xnpkyry8BVaKsg+&k7kDodPyKyF`aoX!s7#4R8`}@RRI4s2x zuz}Zk7uCn_YyJaKL8ky6f(>2Wn6k;@U*)H-46*T`%2JB%6W6}a=_k}t5C2n*27SBr zdA64T{0W_?LtSVeSyAB)whc73^h579Hs7wq_@IY6a4W`#K#ts^4G)87CfrWrB!vU;#foi+PbqSDSct{+qNhcBF?h9D= z8agaWrsgc1xj|2&Mn&>Vto5{x0_t6(zN&HBuOb?&08K_c%hs7giB1e#YN2sDsS>Yb zYH-k`jG~25MRcC=sq;_{$6v~q z@y*6QjG1+JWO~W_Elbv|2o{&373jTo@{Fh{08=z3A~AM^59mJH3ktpSmwa6dY8@xKe-?YT9dSvW%nL9q`#)w##aBtcM5a z6tCYi;3|duL+n+nvfiW=d|E^b7?SZTdheg<_W3pwrp@>O zlP|jI9O_MkE|S+I={!>Z3ypws6kJaL(-ovQ8VgvpN5|d-Uitn&Y+I@>E?_M>K`kEC*Se11sav5J@Dpz>y=jN#qpt7JZI{)W5bZ^IV5pxQKDPg zyrO8UWJ}>!4F%B;nt2{h_QZo(3aTv5UuAF|x|ZF54un^z6*w#K%*XQJEFs^*T>hMH za_=AG?5t44tP<38zsa?&j2y_C1yIM_!*H1!*Hd>%#0}-^;{L-A6`X`X4PU*{7 z@IF5c>4yP3<3f?y(s4GN03*DS(-_je1~dSFYBTXm=5at`%U;Xi-{}elsm=Wfm-p^s zDGCB0m%v(>seEU)NTz>_DHoQvdlb#=`~q^(1SIg+FyarI*IzB!+F9%4aFBL`-Q?oc z!*}$f7-l`=nW2#X(1S>h4?ks6GS?y|cYb|VV7g`Cbzd6!%&2Q) zjDw5#Glp{4W%p$!DjaM@Pp%J;lp}~45DfrHvJp>#>}FT8+G`CqCAL@fiYpf(f6sUs$4V@~u>3uVYUQWNd za9E)p_6T)HwU#dh)S91wO#u@BBB{J)#E1Cw?bh>)lLRojc4V|8iUnQ_rTB4m>oowA98nB879z)o!+JrxUkWM=IZ zJWdkf&}B|3&zUu^oX?SP{(yZuXal@|^l*-{ffD-6J@GvzX zvEhsWdE#q$JairM7DnwoP662qBn$$<`mmCHUBH6yDr{)>+EtAG!k#aJ^a^6bs60UI zWl08G)cW0}Ph@?}!vn(w5U&gse&*3E&@Jr-_Hbj`CEGfkQohZa`Cn)vE?8jKIZAB` zrhUf*NUiC><77|puNEi`Vi4pMp${y`7XEl@ch*d$seRlr2&E0EE}C83i+6^NR)ARm z1c-I^mjF3EE7wZiwHwJS~E_&9^z%SUdtLAe1BE9zIj|CAsn*mLu@q_lB-_0?BV&dq?>3r zOc-Z+YUH+(`;LDb2Wbl=Up5|ho74k9MgdccpaQV3W_MAAz(jzl8!p(JL1A{5{4dO%cehN$NH&W0FX6WZ-M3A1c93xKurXQrVurm{+%sdfR6<`yjR2uP=Ymv zFy}NLv0!S=53VgDDXM(JQC@B?rg)yS)W<;Z)wZ1GLRjpSriZ~B)!29IlUH}JBj8g< z05rL{yDL6&b!Id`3{_|e_-;~6||*;Xmbq!=KzZ8i}Mjqe8%2JG=Pc?o5G$me|tM<$uPkqP`{Ar!Q zmK5^7sgR?jf-X2H%an~@5`;H{j*W`H_+#{!Psx>XIy5n_#Tg*%OEzc`^N!IWz;Y8dQBoI}V<7^LTV$+%y*gw%i0I?E8>esP1ESQ2kJJ(2 z5V(y19psyV!ZNf>{CbTFFh>njr-ni+>Ug^;*D_)Xb)2QXczS4$dUt=EQl0^&GpMj& z%SG+aluf7CJSvWV3V=dSl;v{n^py-~Xc8Mvg+A6Mc`bnoh+OsxJ~|Ri^)y@@OQkxe z9vAy=7AEw$5h8vaE5^nkgd46fB%+k zFA}u!ifp=P=}MD|Uy|X-27`R^SR)pf$9cA-$hf;y!M}R5@P!Z*gh6O zgCU}qD_>r4>ocG}&FDfj-@)FnJWZHYfP9}Pt=Y3lmh6`c2iiw`d%@$PJK?z5uM48; zIfpbZM7q!$1IDa^fTnU(n!-+d4vAke3z4x2s@jPQ3pH;saaF2|E=0sm>zGW*vDTP~ z0LC=G*c&1gzySpW7y^bJ>QO)u^0Cu}h5jBdl8OqTH#B1HfUU3Ucd5!4{88?gU6AC! zqEVo>ob8W$aDWGp3`|7=%8SzUU+ye)h!KFNb)KgR0o-ZTl-ekM6=y0eRKH*&8-(U|f2n!E))r*xr7Gw^njFc(>iDn;tLh zd|YQ+3&N&Ob_7@wLy71NcOe&7*c8`H5p;B`8&tyvi|i)pxJx@PNNA&}RCAK}VIM^w1J;8u`$G4YNZGZCL+3^+@O0a{&@^+{LWtRKn3sv z@&^1*`CJI__5eW8dFc#+uGEA zsu&*#22)Fc5I3*;NoW37cx=96_f{y%5-hPANX3HuzF-_cJK&NaL*so)zF_A8U>i^o zy4M8fe8tDT5<67zP;~QsfV2Q?DZu&Sasiw-`M!{s?+{|#$7DY8FEa6o`gJa^p*6b2`Km>ZvqAK2jiHIyIr!0aT&Wg=R`Sb!+g z#ne7SfQTbG^W>6a7->PEvO`OsP8K**KSu(I81jD6OvKTK0;!8(>&!PYTmVmm#S80I z7O2JUS2PY4a43WFILy)Fq$SV4VbTHMTX*V?!1nO;8w4TpYEVK~dR@>|={SGvU?rT0 zUTL!FDo^l0LXV&2ZoH0*Vas7EDFAA_@W$_cp#BfANY3oU36^cl6M)BN>4xac1wNQ{ z&k4o?LWT0!n8exP%Hclm`XD+jpq^954M4+oZX+P)`9h#YGIgD9P-T0f_`LM?wd}K< z57sSIs1o6!6g1}Ar_ss4EpSl%XGafM3!E$-Q5@wgMg45*e-s9RrWv>u42kho<+m7r zr?~swyRxp9PqUsWER#gc_}&27#n?s;;oTh;tHv2-5)S6Nw=NBt3MY@#7Ul-P&r!`0 zqK~;U_fb^GT(MsOEdpSL_>$~VcX#d zABzgzb^|7dQtnp_AdYT4grzk7X0Z3JXn`RF)klZ9rbFq0x{jY+HN zf2SA_sV7YWiES-YFBG5}kj2C}AF`?V=P;k)-iy&o`8|dbv|WFx84#a4=E?`2El#R+ zXcj*bO$+^N6Ubv5EvqQ=;5~J}0?-cNyb-52dgYSCt=HY8?70?m2_^6#;l><7_Oy+3e`Zp_Z!T>K?>+D$IhU15B zA2nUS3vKe1f-6=ZkwFcngYN=>qXZ~RF7G{PJMPTCMq+ ztuy8QHZ^%=#nfd17IdKy8$)}nxt2z5Xq0TzOIj8Clhw0G#$QZDoA-&jxH0OZ+@Hnf zgJB4)=gm+wped4$;}RSvxbc11t?u9w%4lyn&CJFZK33S5$;}}O>8*bOs~&%X`Wj|k z`QoreAPO#s+9hE+8Zdmec{K(OwR?#IrWX>b-FV!vaUJ3)-_)jz4P%(0CF#+M1^}O) zTv;@(Q(iHh<_N?|GZKX7%K#bN&`94@M514SKZPdHx5K_j;`8CVQuRlO$J*<7`8b&8 zSCeWq@yl{|^$s)Ppe_QKc>Tg>0(AdbDR@)g=9~k@Qmj7r^ z(8|B|{h0?h8t;2f01#pHFa)~QwUmSOprH6OP-qvpU$8E~xHktrOQ6pQDXvC|q0h0( zyfLtO2XFnBrPMWn@S!5-$p~Cy6kou%dQ>-Y#YG7T&6?6y^FKN3>TXhHxMs2cs(BX zIwe;|>@A1I%!9@gdrW9)oK;g<*NSfoJgs|IC14zQ>NmNb4V{L=;f|$)BBFP;FB+9+ z40!Dk+lJx{Aeel6-fGtQEpOBEb!t6rTxb^=1;|hg-9Y1nYvBQdX=Q}G0m*cPN3QNt z>VML-2%JZTzjG#yM8yFe^E2rj&Wv{<03du2zrb~CC*Q%E9#A^Y=;*Dz;)YkiIBD76-iv&KfBIVq>l$X#p>LD&fEb-lzj& zlp5lzuafDD>P71t+t7f!33Q^5PA`{dXj`dJQDqr4)Y1TV8yR8Ld*ODj{*2jpT5SY4 zujl>)1z{XvBfky<<~~4!lB-AfFj9tx>^a$8F`g&HHALCj=D0V$Dk8u9m0qs1 zHik3z5J?9-hol?r2w>WEa|rTmjPiTv+P+f*ihk@7N`L$ z6RnK8=~0P)o+pm67!{tc3uH9HSK6OM`4iPDlTxH~bY!qjvA@)OQqjosfF--8i-Fqv zOYry}BFt}%%|G)$EbJBq|2*teeJBY3{$~+!82_hU$S%Mc0drP4z<-EV$Yfo&Ujfrx z23`T%J8SQmt;%*f0``am*L7l;=sg~2d&yzyDG*T`)Tnnnw164{w}vMKx^JMoK;AkBfEr3ll>y)=e55_=lroI3z!I$nH9AtH=aVK=*D6Y(N0&1HezP zXw#-d2SNYSM<)z8Ifo`?z(&y9lEM9RssUh>wf7YTdTzgbU+vVKskr8Jf3N4ikRXlr zRNw1)w06vcXS?>rG&bmR+MQx_^&I*W>7PTUVQ)VnJWEY(q(`Uiai&gXUxx}4=r*gx}JaR=|8z-7MJ za9>XuBC73mNx#^*y*hoS)m_19h52pnxwLJ`hDPrJ9Hg4ODVa;NwcF~-eHr!jm_1U2 z?}f1B?vR4oS1ZVSB7YO2aXm?*F}u%fN8A4xO(TxH?s-by{A4U`=~1n2mZzxP!a#i` zD{opXP2+Tflm*jd-CoudwWe0Ngk>=bjnuMTBkY<#uO2OJj8}K2!nP(df_8k;wPn89 zvcC%n>Cwp*nXA0R_bHs_Cw75q#l`B`{H>(yPk&}L2VUuRyAL)-ok#Q|c<@>hTsOiy z+o%weT@^OC%z7*w6bV^DP@&EUT#(&o(z$smy=sNKGf&#?&y@=a0YhioEn04z>>S_f zr=EJS=so%h?qTxKD1O_`TEK3pxKUcm>}u*}jp4-9$n?!@ z%?Epg9D}Ane{D&&4PDclnn{FhwA3hGea14Tgt7DqE;Gn`&E~-V&4oM)`u+>$`xu%D zl`mf^WFT*ql5bE)%JVo9g}&wmRxp*AaocYyG4{nRWA`KFer@xO$FVM_?@nLL2Q7?* zZ<2bP|CVtnYzUbTVRYqJmb5vxuN`c1YUSULb`pJgvw$=)g-^z+{gLpv^rAnl;smYQ zu=Yh~gioqZN|RH=Je|}v#huW_zTh~x6+hvvY#MtcOByDD&LRKeL2ERLLIIzv3)ATJdpTTE%==-wBwUE^KnIZ#K_@U@KF4v9p~0w0_)cVlR?LyySFGB7 z>dYgdcXNMnS8F*9f>b|e!DDT#&wQVb|GKn)8-wMq5Yd1;nz*Rj*UQD5NF*iw$K~v&HgZPQ^}dNo zr+LX4K1mu9bh*yR2-$orNlmt7z?J3rU>U=2yix4G+~8zR0}GSQPnQS|cLi>(sgBpt zw_=|5%#?0@s_)eH-#~eCnN2+H$swsLPECuSvMcJ@t&&R_aT7G|d&rShdPGVMn>JW# zJWkHN<DJ)Kf&1scRcK*94Iy&{Xhimq*z<`(gmRj@^`%~Gg0*{YeG>7-dwlleO$}wN% z&PoSd8>-?w_jw79`I&Qa>LYSuYnac{UgmEO1l?FYT1tM#o2b-H&W4tcMB zhYPA)@9Y`Qm{cud_+>&n*=GImy7b}c&hM@LEsWNtKNvn=8-Bj#TKzGUzT#Bvjv3k* zkz*jAFjHfko$O%#YcAus!j@<);F{&!%*eFN$OEqIWrCi@jF$k5RQ=%z@|CUz=~x~D zr21t8nW9`P@u%;L5-+-w(636L8Fa`t?JQ7U8Iw&bpI0vP^q-*6*OiKsTGcXuL~~AV zu42xva=1RXC9_dl|9+t7u_5JRyOL(Xjyhvw^FG)jrtyjV;qt*051Gc7ue>K%`Hc(r z+_h_-rSkvESU+RBcLUbjmjfCID@7YvzwqqeS0r7nK8|}|>c5O3%E-|_x~VS0xx*N5 zlr-5=k^#nZcK7lHlBJBya?)p6J$z5)hT#K!hG(4)%(N$=vcj&)%6CzfN|;CpWygOHg0##9@WmqrBd$fxg=FBN4C7O~GMJwlk@QIn}vV z?mV`*i`y0*H(ZUq@~f=7?p)EEQTs;8UDdZ~>d=y|Nm`wPCE$6!=*3_kBG!S_>| z+>phuyo`?-8%lg(i9nkSr*gkF*18`K==mrs?C zzx0}*W~f|{9QeO_`|hYF_HW-oMGv5G&QU}~1Qnzg$w8zeiWoppdXb`11f)ps%>!6L z0t6#nkq!|Mq$8rz0!Xi+NC`Fc-rk<$@7}l8Z@stHTkqX>|1m=nW+pS=z4xaXO6YC- z@@~VqAEQ#E)_NjibOM4HY0B=X?LN9z9fpZ88VwWN0r`*dOtoCkPkr5b|#2fh7#`UrA=KCiRds8$0JWaSu zJx5LEx+(=MzV9=3<()R6POs}(cP5MudT`Jnwd`Oswt6@D-jBL#g!At_yEEC)-qJV|<~(vuzCu@&=7x8_Rt@`g=f%w@r;M|mA3v{Pihy^nY-e4;wa;6> zzvObAnH58R@hswL@uRl{(r-u^a$I}A<7zp>qK=2Tde5^#L7^i|$hyVEo=C*lgT1=t zADI*(|5WOjv8RgJwa*H13ncQ13-(jZxO z&xI6-e&x4uX1iokpPNUbI^co!-e+Pc075s%0xHT}0SSF9y!TRwX_?no9KS^P2kb0{ z&7Q9Gz4{W`@olImHo4xUkLFgk-%fSdr`6fWN#buAPSbQ`WxmdrL;Sy$u3|5=h07%K zNes7pYmFB$?eeq=Xg9@OOzcZYJ<1y=O)gIZXelz= zTZ8Q>Y_Dib$d|FT6lDqKuJo;@G@41qesRWH`xa=xntA49oiW^@9AwYUi48DFXw@wA zByu~uXUcKM>_ha($D?SlhIlv!E^On?HpPjS=|9kuhia}?Qg=?@%Yc z>5KDYrquE_c6EXhOLK^rBwmlC3ti#o;Y#a`M|OzDuE6{aeVqZz*u<}N8l1czOVt_+ zjM*wh-`| zEi$a#3p85PRgJc4wVIn9Z7${6$mJPbZqHhd%#JiKb~&@~NOK`KQfRyEu7}V*M8tl} zuhT%Q6p)%%eJ8A1+BE>jF6n#LO@&bUWmX<-&GD|x^|vnbWiHhm*vb&S@@yq+GS&QD z0;3A<&6~y4-9|?D(`u`nMWtN!CPqdU{SN0YirQ2n$hT=P{Lba=qkaPw)p85`n@`gx zxKp|@LN=Zjxm7l5rfgA< zYqmL-)i7UvFAc^vEUkX_n&IqSOU>n`@(QJOi*XEj>r_;Hp3Ber55eq~zRlur*m7dH zm;z$TZ1_Wx!>XaxM$86i1dr1f605TLFAOxpYZzd3MeL#7Ef4+T7minbSho60!h-;z zo2mCG8|qZOsCl@rG%%lvsZgOgj|ku~jdorEGa>ik6N^3#H)GWV^$FT^| zAoyUI5$k%&QNfU?BrA9mPk4MAiB32|w%*>Inf5qcDaw~1j&W|-uE!Db9Cg$`Jv8+B z#L$IZm?vu=sinPYkFar8`H9{ra3|XTe{NIfji#w z@c+0-bR2;(l)c27?Rt&nqZoVgtA{>rM*f#XomEk-*nNJqQ=F52Jbg}tH#T&SDv^q|F;2gV|5L1{{25<%F4q?e{ zZ9KdCOeL!(G*3n1QSkT`U|}iB)u|E(Dtvs~etxgHIV%ydLGI5d&S&XNrmI?<_iAKH z)%mJ=Q&{VPtk-YfN;r@Yo`TkAzd#jsxCv#!@PlSUyJl`dnA+A38#vvYTpQ)9Xn!3> zEnHI=8ZbJd-l*2`y-MfN0my+;j&DnMfd$tYJrMyyoGL$j7oL|gn*j_3Eh~D?%a>rn zNp5c6d6Isb#xyCG*ZHxWkkD3(n|RV!iq%WCd&RmRd?xf#`?#IHXaOd*;?KrE z&h}iNpes0weE2Pz^4jy$D>yqC>*@U?EyLNql*OcAv@uH14tG0ZLNw-7_1w(lPlKb;CERI)59Xx~?dEzt3 zPvt!g&-=G}oZbJs)0g&`#u(@A2TDz=0CF~)g1E4Pf*YCf`{6Bm`oc56X8|THp&f1P z+i!QJn6u#Y!;YRcp)3a`6n~MHzZ`@M0)sa^z*l^Jp178|6ZcArl?B~@$5;@fJZvCk zdd9V<{y}rX7yc`O*?$EjR5eB%w;zO{tZZE~KS#kUQ0iFu8U3dfDC%^tYUJO4Prc{V z$x~V|Z~@fk-tj?Sn8$6d-jOQTHtwh#n5baian=K^gA<6^EdU$Ni_U9{tLW$bta88U z@Qt16mnc`?Iq9=}P4JK*c?X7Gbopa=9Wg$$B%~m>H4qfLwosZIy9VojmX3N0Y6?|l zHjju+Npt{@Ew;^@Rt5Z0+^1CYCv|m3GGd+ly3}X<2XUROSGClrRhjf%CU}WZz-;po zB%TdlT6E{fA6&^d^{AJo+VID*>Y?NVh%QhRPF^trS~s@+x6V6#iR2WkxG0mwFM)S9 zVPaG}gsEm{DkUEE!xj-)#nt@^DO%BtWd)yxHm1beM-RyK^cpas!VAu<>}Om})IY1_ zg~uW2+K%?CTy;F+oY?3?o5zY<=j`medy`>d+kJp5*t^tbi%U*PIMDC<^y`x?NmtBN zMd(0mlRxL4C|mMhV&<}}m8=aX_gz}@hAZi*9w*>_L~T#T{7e+a36F}es4 zVrv_6LVmV~*9DFVDRH*Lf0szQocC0+94t)?35zf4(~RI%Br0iv3Zr8#%2c;7IdpET zK*qfEWW>x}OmHOA-0{QmptDF! z`#;R^Yh)|oG`HJ+uWI^}6v~B7x*tcKcC4=Dz2g?FBPM>+^2+s^KPAN2)sCO{#NZ+x zS#|f07S=K9ym_+^lmjSv0e-s zuljUmDsIYxjGlI)^LPQ{lLe!zy9SkkrEAUnk4>3*4mRiP$A)r|--LapCVFejqB}wb zI`&R=zj6Iev%;T?ffO~C!Q-%l&x>y`$fHggk28a7DIwD4Oy3-(R6VXv?@XPKqM7k}vg&H}AKh%Ep6K%35YD)nsYfPCIm?-Vw~*l?LeG3k$hD z3Rp2A8WJ|oZfsPS#BPQ?gpNNwl`x=ds{6xs`WNDd3E`NsAN?xq4~zfVr_q!9EMASX zkcSP#ROr8f$NpQp+*o}m^@ZZeOr;8L$^*7CnySor% zKwnwU@V4&mjm&S{K9}BZR$so+JieD?f^)v)eFasn`pKD{S7-UeU&OjDlwO2o&4rEL z-P(C8WsoKeKnd;GGp?%QZ{42B9!E?kK`Nt8D_1Y|uXf3*WvqEB;6|5DW&9<5D(*)5 zoU6vu=C+dMgvN%>u7|sPM9#@{bAAxH$^<^m=eT}nF6$%8J(wi}ye^^JAJNWx|F*gD!Q%F%QYIEOwS zN$wXpHdSwSJ~Tx1BopvF8)Y~FqdX_E9Zn7`u!+T0Kqp{D$b))?a&nj{X%sD8<5I92 zP|jWx<~lmt2ih4-GBdT&-7BhwcrOwq&JE&~+y$@dc8^e&iO+!=asZDr5o z8r%LyZa4J2y*&rv_4d`$A{=H{r`<;mlf_4 z&<5W2c-D_xHLwDpRzNryW-OT_q$<*a-S}}mM&H*~ef^PMft0_L-9hr-t(vZCeUU2yhN3FjPii94i+Qf^ zgj3QJK)_=xfivCwi{)^(($U-?>7_N)f%52T_znC`5A_(CZ4LC zbHSYMsZ#NlUP??`&<X@ynkMb7X2ROR7` zEB3ddf|Uy6CRMi7DyeadQ%%DAPwfl}+I{$ahey0&X34^rVzGuE4D4<1ul z%mHjI*RUlvNloqe*Sd!{^sju+NyLrR#SVR~!<(`1^~zUz4Q%#K-ctloBCgrm_#CaZ zHn5p`D<(wv&*})e3yk(F({RpyK14+p-+`fn``P?X7~_|yd%1GNP|hNWh?ca!q&*Zz z1e;eIPupqh#?hS%W>-IS!lK@B|8Z`WKbw2!+W)>>MTaE1H&pzdv=WxZZPI+QON#ZE z(jXy6?8tJlQAUrc&+wIwF|c%rVlFIx*v)J8RpxgPwGCqvuht(fg%+HQ$Kx&MFgMyj(LuD+QfBhVW=g z%c3ck7ICl8PaQU*@3HZ{fx^{Uxpsr)<8ES8Fd< zjG)K_v&%)zz3dz94!9hbgy9bltSS(Z<9twte}1c#ZlPQ7GD0or$~;AvNb6 zBjf=6c$iTFRpnaa$y1qR|E@H2Dp)rOC4AwS}tJ(&q7H_r2<+p^>4!+ZFvYhEFs{q$mR(#= zuTOCKvBxfbYoC9R9{$uT;F3b|&Bp?zFn6O^gAJdMy)yVy6TQ+UG)E*AhcOb>Uo6y; zrhTblU6F58i+h-?B#(-g3(IW{AMSV~VJ?i6kv8qPnJeN1itI=Ahh2B_73K&l$9rPa zZKpF;u+UoJ4hAzg4S`?%WF<${-3R|y$HUSn>}y@1 zd%d)W=7_~1&L;Yq9vJBT%tK6N1zj?8@KzUa%5ljh>|z4D`)Ev=CA>ZuYg{;*H>;%u zbI(?axnnFzRcG`|vlDyw@=U|_nVI$KCooc)KhA|tf#bHIZC@3$H#Y9|%^}3Mv9QS0 zwX)V1`TRxD3c(c$xLNn-4>QnqZO?0O0k%<18Cn;$xWdS8-36T+S_bDYVI%l14>*S{ zO6nHq={`Jm>fYGHCE{B1<_5TlWlJg9A5lF7P;`4R;y$^aSys}?$CO+=f0bv}3tMZ}iQ1C|qWTs@g=-4GMlhgFIWx z7Ovc-RO*%-MWJ=YNxaWl1$4{d|A#sBZ=m}B%$8c3Ep`nJ213McO`gbJ&=qLi+{p8M!Nq*hPs$PEzUnu3LI;828#W28ZhPq&1rVq1G2uh`9> z#g3Vu339av4DC)x$hk2HHd@=;=Rab6aoFtC3z{BV0FhBw3it>L3s+dJ+nqA_iR7^W zSyUJqQIc9*1)<~zh~|)6iJb-YA#9^;$qsx`P#+e1S7N7H;Whe%Y3=58p-(#`Cz&v( zSOA&N247kMmPB_7SNVauLrqpqsq~LjkXq2^pvXZA3u(oRO?x~m76Js@EVEPws?6FQ zr?^AN_`BBmT9dKHKI)RMC9UUvL~j-agAmIAb1=C(I}PZq7^E7^^-J7kvtXYR;-9O? zTU^+MD!qY6FcdH~r6=opWY_Pe*g9!ayMHQBZiaFLinuMZ9-a)O7Xl#-B8MT~Vt!(b zp=7*P9=-;vK-UVD5GrAvptlsx-a~vFfN~o65TKPnbAzZ12rk5OIkL`t_c!BcFkx@$ zTJZEQn2a8DuDMEBv#e$TMTDW`I#TQUZ&4WG88<#;Xzb&^Qb%<$E6WYBA{`5^F`SdY zV^QLV>MQEIs|6sh1CVn*H$7g|F9Iu)570pI?J(m6M#+2P?<6Q}yYAqfbW5=Ln0))N?+Qo!#2xx2|`lHZfr~*%G#Ey`Z21Bse*4@U9 z$}R*Myu-jK4|8$8*w`gV?FJ6a+I}A&$`@QRsP%6?Q7m|xw#NhEasq2~3jR$(|99^E zv*xrX;p2sJI|Ux)HpKaC+w9+=dQ97-GMw=Ne>|Rw=TxL`&p7SD9ogoa1Z`{lCbn$v z$6YiBGig`4-se!Ygl7$I+FsQdc*Npp1Y9&YS+(=NvjB(@7)n{Y-%aF0|0eZ7usR+;nq;;K*0{HU$rllrOpGTVv_ zQiR+zds|i8L@L3i-!Dy!=#wjgMRY<{)UzZXY}2?`@fsABg1 zymp9$p^_>Tw~cIUl75q{m86OW&v_2>;D?Rh*Qw8AXkO2`m^0}c9tmLakB$M4Wrs3? zOJe>E4yvbH9Om~I@4qaiS*CicgQFvyOn?{aA}-PVtj61w~WlL#FBi0=u_<#91s zebiTH|JR_XbYkXm} zd`yGz+vukZyQF+~d6sGkOmO^T!&W~#5zw<^K6|V41v5_*-_`1q8DdWL105;5E2JN} z3T=!md9S9SJ@LBo^MSs!Mhz>;H*a)%c@*LK9CSiOl!-u+@3k4`6Gn)1vP-c;VHLNT~!oH|fccHXc98~6uU~1PAdfnb% z-kFi2-9T-6>`uDoQIcH6=2`QdkWT4K8B zb9m$irzL&=!Yo6WX2L=p=A^D!q6vT|RKEhHDsP&0ei0?cKnJ%6ly_yaLj&BaTm#rQ zYhNG;r{IY4Lsi@&vL+{ zO~(KVWM#f3LR*s+jV(+uCWPNT0nrTIJ^6$w6Z`hZEzR_W!mA~DEE2INM|w`Ue+T@t}}!AFV(LkQh*WivrYPU#JDL>}mGC6xB@2 ze&B6?)6vxY>_c9^KH?ack_gm-l?RPxbXoQpFirf*obq_p<2hlMFl5o;1^W_Einc6x zZG%e7Ix+Um1#Li>cBX?KhfrvlL8ayYAeH7={fgN8Y5oiz6^@}$OXk4R*{GmIZzyW~ zhoJ*Z50u|2W%xT0U!0 z3Y7@*HI!8G+$?Wt+At(Z_<*Uz4qe3sZW%zN%5AU)qMEBpL?y?m>%Y{q=vI7Gn^0+@ ztgSxk7NMwP@M>#1 zmFPK|Y61dRhun`;$bTF^9b9OuQZDteVdKS;41~#=GWU80pD#RfYw_XN0VD>RmXP~4 z(9jY$KdrIg+s=M)d*&VaHzYY=s5=IRfgxip(amSC{YX3s>~+Ws!mO>=9Dnp_Ev}*e zfC*^Sx4M=aOQB;hR8}tcCz#hJ*Ux?uuw6d!dKU7hdtc|i!x0HJ@X_17rp&6Ek59qqL5zK!&;(^mpyqt1 zeVYC}-By0E3Y!OaGHZ(z8uNv$i-y+kKl}X?eC7fH%1qe74j}#s`PEUIf!I2QPE1JzGev8ke_y|HCWw|Fpcm+hWQkwdEn>sK4 zWlpVQ@`asNP#0CW5-b7|9maB9Yb*Cw)9$_azjgimJSW;nNmubv#%4^EnphDhQSP4V z6o@FG_Tm~CcoZL5skXds8!Nficuc!ht`Glp*9)2Hyd2>7N!+c^Yn50L4LTkc+hYQU zfOhoY)WFo9o?fH8Ysy*7vV}{(ztBhaah5jmp`bi*@ROX!^!qAf7$j>H1lwR5%jxnx$w>pJ3gpW0YLe8NF8c&nd+ufDf+DdDb- zj^~OmYE|)M>#FzreRT4yQTYU-I?q)*?-%AWfdhw<9W5MB=kC{LI!-o1S zZdjK(Y};s%c^)hVh>A%q-&vyzV9{3PUqQO)h9= zKTw5>j;i+~_n(U3+feu$nv{)$$hgH(=xJhatGMLU;dnp+N+_>5ndATHL~%LT)%~jk z*$Ym+>s?#$M2(Lh2p-pEPdy5;Z!9|lI;8k6#kh|6{+JH~)uzRyCW30fkzS`Y@Uiq| z3=4h(4)(bree~q-*b=wBB#;9gLw*A^1jDY5P~wsc>b<6^t_bDyx1vAF-$Bp|=BJ|^ z_*!Woa{Xh5d9t-pN;04pBisD`r!Dr}z{>k#m&UUSv?L)ADwpa9nL{8DkbyTH;4cB& zcxY_(KY4m6@I1S?YREjwQGN!%Jw=SA`EfK7aOtD-b*@E=Dh~80cDm4Zvcvfz^Y{2Y zjy#dLYcX{o=LXPQF*`f0MPckGwJSI{g6bqayr=AMW!VEZ&H@)H89k83DBqJ?`)B_7 zN8hRoG41H;oP-55RV#J&)9YT%@o>L#6G-=fhW%;?b+p~F*%{}YVFMBV#LTO32sY*$ zul>IUQ9m>A$e2%$vMcG7ux4s__qP{>Mevi4Ao&~s0)}WK;`0i!r82r-7q=SeM?Ey0 zDv!T4NdBtqMk`M<7;i)QPxB4tn3JY_T&fpvx`tK>#9$X>QG#5%WIo9qYO3MFimM&4u#JFd zXs=SrpGU388)GxG&x-N)zSi7SlokC)XaI|i-flJB$7^_+H)ZEuMfK>Q;0jr--txrK zrak@V#TX1y*ZHBK&d1g#N&~WlzXCtbbGuhJGFaC9sE|DYfY=VQQU)=b-xI#%#pS7w zq>rZl4e&lT?JR?9+@%SLRfV?j@vYV$DYwYQG?({8rr2NRLazoV`doR}(Ni1S=|ZR9 zY@Gk2wX8`^&UQ9{{Bl48tD3?e|x*~ zJy?bi=9TpNaHT@mksV2aoRch;Z!;WZ$DMxJ<6eT_mebcfFZ2!lo2BTn7yqr$gyeRj o*C@67b$UdX(r@zq+T(kNa?X5u8mAcW09|2NMYU@=3Z{Yo1MnXG9smFU literal 0 HcmV?d00001 diff --git a/screenshots/widget_vtxadmin_fullscreen.png b/screenshots/widget_vtxadmin_fullscreen.png new file mode 100644 index 0000000000000000000000000000000000000000..923e4a62a436fb5ae92ca06928ed302503cf2e9e GIT binary patch literal 26645 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

OlLO0y%?eAM*>!iw2+}6 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 Za71X^iM8Rc+>6Z9IvR$z%hc{h{tp5VOlAN8 literal 0 HcmV?d00001 diff --git a/screenshots/widgets.png b/screenshots/widgets.png new file mode 100644 index 0000000000000000000000000000000000000000..2c2ff9147e7b2c32f9ff7634848050b15a659b1b GIT binary patch literal 22713 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 Date: Wed, 11 Feb 2026 15:15:13 +0200 Subject: [PATCH 006/218] ui tweaks --- color/WIDGETS/ELRSTelemetry/ui/hd.lua | 2 +- color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 2 +- color/WIDGETS/ELRSTelemetry/ui/small.lua | 5 +---- color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 2 +- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 4 ++++ 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/color/WIDGETS/ELRSTelemetry/ui/hd.lua b/color/WIDGETS/ELRSTelemetry/ui/hd.lua index b047f50..31d9d13 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -148,7 +148,7 @@ function WidgetUI.buildNormal(w, h, opa) if Telemetry.statusText() then return 0 end - return DBLSIZE + return MIDSIZE end, text = heroTextLq }, { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index c6f06fb..8406bcf 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -121,7 +121,7 @@ function WidgetUI.buildThird(w, h, opa) if Telemetry.statusText() then return BOLD end - return MIDSIZE + return BOLD end, text = heroTextLq, } diff --git a/color/WIDGETS/ELRSTelemetry/ui/small.lua b/color/WIDGETS/ELRSTelemetry/ui/small.lua index a15f114..7009153 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/small.lua @@ -139,10 +139,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, color = heroColorMismatch, font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE + return BOLD end, text = heroTextLq }, { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 8cd92e7..8801da2 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -31,7 +31,7 @@ WidgetUI.fonts = { quarter = { status = BOLD }, third = { status = MIDSIZE }, half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = DBLSIZE, detail = 0 }, + full = { hero = MIDSIZE, detail = 0 }, } diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index eccc469..72f572b 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -94,6 +94,10 @@ function WidgetUI.buildSixth(w, h, opa) { type = "label", font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = detailLine }, } + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end WidgetLayout.row(w, h, opa, columns) end From 0fb9e999105e19d4fa55c0f9d5c9dc934194d90c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 15:30:21 +0200 Subject: [PATCH 007/218] snooze warning for 60s --- color/SCRIPTS/TOOLS/expresslrs.lua | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index 0d13f1a..e799152 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -21,6 +21,7 @@ local App = { -- User acknowledgment warningDismissed = false, + warningDismissedAt = nil, -- Active dialogs warningDialog = nil, @@ -41,6 +42,7 @@ function App.reset() App.crsfModuleChecked = false App.crsfModuleFound = false App.warningDismissed = false + App.warningDismissedAt = nil App.shouldExit = false Protocol.reset() end @@ -1591,10 +1593,12 @@ local function handleWarning() App.warningDialog = ModelMismatchDialog.show( function() App.warningDismissed = true + App.warningDismissedAt = getTime() UI.invalidate() end, function() App.warningDismissed = true + App.warningDismissedAt = getTime() App.shouldExit = true end ) @@ -1604,11 +1608,25 @@ local function handleWarning() message = Protocol.elrsFlagsInfo }) App.warningDialog = true + App.warningDismissed = true + App.warningDismissedAt = getTime() + end + end + -- Re-show after 60 seconds if warning is still active + if App.warningDismissed and App.warningDismissedAt then + if getTime() - App.warningDismissedAt > 6000 then + App.warningDismissed = false + App.warningDismissedAt = nil + App.warningDialog = nil end end else App.warningDialog = nil - App.warningDismissed = false + -- Only fully reset if the 60s snooze has expired or was never set + if not App.warningDismissedAt or (getTime() - App.warningDismissedAt > 6000) then + App.warningDismissed = false + App.warningDismissedAt = nil + end end end From d254371e39ea4bf3460b7fbb33b83c4ebad321e7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 16:27:31 +0200 Subject: [PATCH 008/218] Rename ELRSlib to ELRS --- color/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 4 +--- color/SCRIPTS/{ELRSLib => ELRS}/crsf.lua | 0 color/SCRIPTS/TOOLS/expresslrs.lua | 1 - color/WIDGETS/ELRSTelemetry/main.lua | 2 +- color/WIDGETS/ELRSVTXAdmin/main.lua | 2 +- 5 files changed, 3 insertions(+), 6 deletions(-) rename color/SCRIPTS/{ELRSLib => ELRS}/crsf.lua (100%) diff --git a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 3cb2661..6481b36 100644 --- a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -926,9 +926,7 @@ local moduleFound = (config.scenario ~= "no_module") -- no_module scenario has no entry -> mockTelemetry returns nil. -- ============================================================================ --- TX-side sensors reported via CRSF link statistics regardless of RX connection. --- On real hardware the TX module always sends these; the simulator mirrors that. -local txModuleTelemetry = { TPWR = 50, RFMD = 7 } +local txModuleTelemetry = { TPWR = 50 } local scenarioTelemetry = { normal = { diff --git a/color/SCRIPTS/ELRSLib/crsf.lua b/color/SCRIPTS/ELRS/crsf.lua similarity index 100% rename from color/SCRIPTS/ELRSLib/crsf.lua rename to color/SCRIPTS/ELRS/crsf.lua diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index e799152..50e9e8c 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -1598,7 +1598,6 @@ local function handleWarning() end, function() App.warningDismissed = true - App.warningDismissedAt = getTime() App.shouldExit = true end ) diff --git a/color/WIDGETS/ELRSTelemetry/main.lua b/color/WIDGETS/ELRSTelemetry/main.lua index 7f54527..a2c1503 100644 --- a/color/WIDGETS/ELRSTelemetry/main.lua +++ b/color/WIDGETS/ELRSTelemetry/main.lua @@ -11,7 +11,7 @@ local name = "ELRSTelemetry" local function create(zone, options) if not _crsfSingleton then - local getCRSF = loadScript("/SCRIPTS/ELRSLib/crsf.lua") + local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") _crsfSingleton = getCRSF() end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") diff --git a/color/WIDGETS/ELRSVTXAdmin/main.lua b/color/WIDGETS/ELRSVTXAdmin/main.lua index ee2548f..f6701b9 100644 --- a/color/WIDGETS/ELRSVTXAdmin/main.lua +++ b/color/WIDGETS/ELRSVTXAdmin/main.lua @@ -11,7 +11,7 @@ local name = "ELRSVTXAdmin" local function create(zone, options) if not _crsfSingleton then - local getCRSF = loadScript("/SCRIPTS/ELRSLib/crsf.lua") + local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") _crsfSingleton = getCRSF() end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") From c4e3989b629393a9ecc28eb220822ebb93ac8fa9 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 21:04:02 +0200 Subject: [PATCH 009/218] Fix lvgl warning --- color/SCRIPTS/TOOLS/expresslrs.lua | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index 50e9e8c..1d98a2c 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -1715,9 +1715,10 @@ end 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+, 2.12-rc4+, or 3.0+ needed", 0) + lcd.drawText(5, 10, "LVGL support required", BOLD) + lcd.drawText(5, 20, "Color LCD radio with", 0) + lcd.drawText(5, 30, "EdgeTX 2.11.5+, 2.12-rc4+,", 0) + lcd.drawText(5, 40, "or 3.0+ needed", 0) end -- ============================================================================ From ae9ec44f36394a253156314ab57d09c847c352b2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 12 Feb 2026 00:56:36 +0200 Subject: [PATCH 010/218] improve loading text --- color/WIDGETS/ELRSVTXAdmin/loadable.lua | 34 +++++++++-- color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 69 +++++++++++++-------- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 66 +++++++++++++------- color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 65 ++++++++++++-------- color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 67 ++++++++++++-------- color/WIDGETS/ELRSVTXAdmin/ui/small.lua | 71 ++++++++++++++-------- 6 files changed, 246 insertions(+), 126 deletions(-) diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/color/WIDGETS/ELRSVTXAdmin/loadable.lua index 59aead8..f83078f 100644 --- a/color/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/color/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -645,12 +645,36 @@ end local VTXDisplay = {} -function VTXDisplay.statusLine() - if Protocol.isActive() then - if VTX.state.band == 0 then return "VTX Off" end - return table.concat({VTX.state.bandLetter, VTX.state.channel}) +--- True when VTX is tuned to a band (band+channel should be shown in fixed column). +function VTXDisplay.showChannel() + return Protocol.isActive() and VTX.state.band > 0 +end + +--- True when a status message should be shown (loading, error, VTX off). +function VTXDisplay.showStatus() + return not Protocol.isActive() or VTX.state.band == 0 +end + +--- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise. +function VTXDisplay.bandChannel() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return table.concat({VTX.state.bandLetter, VTX.state.channel}) +end + +--- Short status message for non-VTX states, "" when VTX is tuned. +function VTXDisplay.statusText() + if Protocol.state == Protocol.STATE_NO_MODULE then + return "No module" + end + if not Protocol.isActive() then + return "Loading..." + end + if VTX.state.band == 0 then + return "VTX Off" end - return Protocol.statusText + return "" end function VTXDisplay.detailLine() diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 8801da2..cf3489c 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -43,15 +43,18 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ Protocol = Protocol, VTX = VTX, }) ---- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) local wide = w > 400 local c1w = math.floor(w * 0.22) local columns = { + { type = "label", color = VTXDisplay.mainColor, font = BOLD, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, - text = VTXDisplay.statusLine }, + font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, } @@ -65,16 +68,21 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: status + power, cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/4: band + power, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + align = LEFT + VCENTER, + visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -87,17 +95,22 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: title + status + power, cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/3: title + band + power, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + align = LEFT + VCENTER, + visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, { type = "label", font = WidgetUI.fonts.third.status, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -112,13 +125,16 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/2: title + MIDSIZE status + detail + cheatsheet. +--- 1/2: title + MIDSIZE band + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.half.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, @@ -131,19 +147,22 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: title + DBLSIZE status + detail + cheatsheet. +--- 1/1: title + DBLSIZE band + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, } local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then + if cheatsheet then rows[#rows + 1] = cheatsheet end diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 72f572b..80f3d9b 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -83,14 +83,17 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ Protocol = Protocol, VTX = VTX, }) ---- 1/6: single row with status + compact detail. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/6: single row with band/channel + compact detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local columns = { { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, - text = VTXDisplay.statusLine }, + font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel }, + { type = "label", color = VTXDisplay.mainColor, font = BOLD, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, { type = "label", font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = detailLine }, } @@ -102,17 +105,21 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: title + status + power + cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/4: title + band/channel + power + cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.28) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -139,6 +146,7 @@ end --- 1/3: title + status + cheatsheet. --- Fixed-width status column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.28) local rows = {} @@ -146,13 +154,19 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, } - -- Status + detail row + -- Loading state: full-width status label + rows[#rows + 1] = { + type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus, + } + -- Active state: fixed-width band column + detail rows[#rows + 1] = { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, { type = "label", font = SMLSIZE, align = LEFT, text = detailLine, color = COLOR_THEME_SECONDARY1 }, }, @@ -174,13 +188,16 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/2: title + status + detail + cheatsheet. +--- 1/2: title + band/channel + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, align = LEFT, text = function() if not Protocol.isActive() then @@ -211,13 +228,16 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: title + MIDSIZE status + detail + cheatsheet. +--- 1/1: title + MIDSIZE band/channel + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 760c39e..8e8b513 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -40,15 +40,18 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ Protocol = Protocol, VTX = VTX, }) ---- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local columns = { + { type = "label", color = VTXDisplay.mainColor, font = BOLD, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, - text = VTXDisplay.statusLine }, + font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, } @@ -62,16 +65,20 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: two rows. Row 1: status + power. Row 2: cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/4: two rows. Row 1: band + power. Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -84,17 +91,21 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: title + status + detail, cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/3: title + band + detail, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, color = COLOR_THEME_SECONDARY1 }, }, @@ -108,13 +119,16 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/2: title + status + detail + cheatsheet. +--- 1/2: title + band/status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, @@ -127,13 +141,16 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: title + MIDSIZE status + detail + cheatsheet. +--- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 814b8b6..917c64a 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -55,15 +55,18 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ Protocol = Protocol, VTX = VTX, }) ---- 1/6: single row. Wide: status + detail + cheatsheet. Narrow: status + detail. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local columns = { + { type = "label", color = VTXDisplay.mainColor, font = BOLD, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, - text = VTXDisplay.statusLine }, + font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, } @@ -77,15 +80,15 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: two rows. Row 1: status + power (+ pit mode when wide). Row 2: cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/4: two rows. Row 1: band + power (+ pit mode when wide). Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildQuarter(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local row1 = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -98,8 +101,12 @@ function WidgetUI.buildQuarter(w, h, opa) } end local rows = { + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = row1 }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = row1 }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -108,9 +115,10 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: three rows — title, status + detail, cheatsheet. +--- 1/3: three rows — title, band + detail, cheatsheet. --- 480x320 has enough room for a title row. ---- Fixed-width status column prevents layout jumping when values change. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = {} @@ -118,12 +126,17 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, } + rows[#rows + 1] = { + type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus, + } rows[#rows + 1] = { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, color = COLOR_THEME_SECONDARY1 }, }, @@ -136,13 +149,16 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/2: title + status + detail + cheatsheet. +--- 1/2: title + band/status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, @@ -155,13 +171,16 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: title + MIDSIZE status + detail + cheatsheet. +--- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua index f9595c7..04ae91f 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -77,14 +77,18 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ Protocol = Protocol, VTX = VTX, }) ---- 1/6: single row with status + power + pit mode + cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/6: single row with band + status + power + pit mode + cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { + { type = "label", color = VTXDisplay.mainColor, font = BOLD, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus }, { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.sixth.status or SMLSIZE end, - text = VTXDisplay.statusLine }, + font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -100,16 +104,20 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: two rows. Row 1: status + power + pit. Row 2: cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/4: two rows. Row 1: band + status + power + pit. Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.quarter.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -130,18 +138,25 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: status + power + pit mode, cheatsheet. No title on small screen. ---- Fixed-width status column prevents layout jumping when values change. +--- 1/3: band + status + power + pit mode, cheatsheet. No title on small screen. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = {} - -- No title row — too tight on 320x240 + -- Loading state: full-width status label + rows[#rows + 1] = { + type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus, + } + -- Active state: band + power + pit mode row (no title — too tight on 320x240) rows[#rows + 1] = { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.third.status or SMLSIZE end }, + align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + children = { + { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.powerShort, color = COLOR_THEME_SECONDARY1 }, @@ -158,13 +173,16 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/2: title + status + detail + cheatsheet. +--- 1/2: title + band + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.half.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, + visible = VTXDisplay.showChannel }, { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", font = SMLSIZE, align = LEFT, @@ -183,13 +201,16 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: title + MIDSIZE status + detail + cheatsheet. +--- 1/1: title + MIDSIZE band + status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusLine, - color = VTXDisplay.mainColor, - font = function() return Protocol.isActive() and WidgetUI.fonts.full.hero or 0 end }, + { type = "label", align = LEFT, text = VTXDisplay.statusText, + color = VTXDisplay.mainColor, font = BOLD, + visible = VTXDisplay.showStatus }, + { type = "label", align = LEFT, text = VTXDisplay.bandChannel, + color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, + visible = VTXDisplay.showChannel }, { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, text = VTXDisplay.detailLong, color = COLOR_THEME_SECONDARY1 }, From ffe847d84566d3d60b36797fbcb3b6da6090fceb Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 21:03:48 +0200 Subject: [PATCH 011/218] Add new BW Lua script --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 1499 +++++++++++++++++ blackwhite/mockup/README.md | 10 - blackwhite/mockup/elrsmock.lua | 24 - .../SCRIPTS/CRSFSimulator/csrfsimulator.lua | 26 +- 4 files changed, 1524 insertions(+), 35 deletions(-) create mode 100644 blackwhite/SCRIPTS/TOOLS/elrsbw.lua delete mode 100644 blackwhite/mockup/README.md delete mode 100644 blackwhite/mockup/elrsmock.lua rename {color => global}/SCRIPTS/CRSFSimulator/csrfsimulator.lua (98%) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua new file mode 100644 index 0000000..f7e41f8 --- /dev/null +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -0,0 +1,1499 @@ +-- TNS|ELRBW|TNE +---- ######################################################################### +---- # # +---- # Copyright (C) OpenTX, adapted for ExpressLRS # +-----# # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- # # +---- # BW version for EdgeTX (no LVGL required) # +---- ######################################################################### +local VERSION = "r1 BW" + +-- ============================================================================ +-- Compat Layer: table.concat polyfill for BW radios +-- ============================================================================ + +local tableConcat +if table and table.concat then + tableConcat = table.concat +else + tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +-- ============================================================================ +-- Forward declarations for modules (needed for cross-references) +-- ============================================================================ + +local Navigation +local Protocol +local UI + +-- Popup compatibility wrapper (set in UI.init) +local popupCompat + +-- ============================================================================ +-- App Module: Application state and coordinators +-- ============================================================================ + +local App = { + -- Module check + crsfModuleChecked = false, + crsfModuleFound = false, + + -- Exit + shouldExit = false, +} + +-- Reset App to initial values +function App.reset() + App.crsfModuleChecked = false + App.crsfModuleFound = false + App.shouldExit = false + Protocol.reset() +end + +-- Module check (caches result to avoid repeated checks) +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + + App.crsfModuleChecked = true + App.crsfModuleFound = Protocol.hasCrsfModule() + return App.crsfModuleFound +end + +-- Coordinator: changes device and triggers UI update +function App.changeDevice(devId) + local device = Protocol.getDevice(devId) + if not device then + return + end + local prevDeviceId = Protocol.deviceId + local isSwitching = (prevDeviceId ~= devId) + if Protocol.setDevice(device) then + if isSwitching then + Navigation.openDevice(device.name, prevDeviceId) + else + Navigation.reset() + end + UI.lineIndex = 1 + UI.pageOffset = 0 + return UI.invalidate() + end +end + +-- Coordinator: opens a folder, loads its children, and refreshes UI +function App.openFolder(folderId, folderName) + Protocol.flushPendingSaves() + Navigation.openFolder(folderId, folderName) + Protocol.loadFolderChildren(folderId) + UI.lineIndex = 1 + UI.pageOffset = 0 + return UI.invalidate() +end + +-- Back button handler: navigate back or reload at root +function App.handleBack() + Protocol.flushPendingSaves() + if Navigation.isAtRoot() then + -- At root: reload everything (like original elrs.lua) + if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then + App.changeDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + else + Protocol.allocateFields() + Protocol.reloadAllFields() + end + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, + { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + else + local entry = Navigation.goBack() + if entry then + UI.lineIndex = entry.li or 1 + UI.pageOffset = entry.po or 0 + if entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + end + end + UI.invalidate() +end + +-- ============================================================================ +-- Navigation Module: Folder navigation stack and methods +-- ============================================================================ + +Navigation = { + stack = {}, + -- Navigation entry type constants (integers to save RAM vs strings) + TYPE_FOLDER = 0, + TYPE_DEVICE = 1, + -- Synthetic folder IDs + FOLDER_OTHER_DEVICES = -1, +} + +function Navigation.getCurrent() + local top = Navigation.stack[#Navigation.stack] + return top and top.id or nil +end + +function Navigation.isAtRoot() + return #Navigation.stack == 0 +end + +function Navigation.hasDeviceEntry() + for _, entry in ipairs(Navigation.stack) do + if entry.type == Navigation.TYPE_DEVICE then + return true + end + end + return false +end + +function Navigation.openFolder(folderId, folderName) + local baseName = folderName + if folderName then + baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName + end + Navigation.stack[#Navigation.stack + 1] = { + type = Navigation.TYPE_FOLDER, + id = folderId, + name = baseName, + li = UI.lineIndex, + po = UI.pageOffset, + } +end + +function Navigation.openDevice(deviceName, prevDeviceId) + Navigation.stack[#Navigation.stack + 1] = { + type = Navigation.TYPE_DEVICE, + id = nil, + name = deviceName, + prevDeviceId = prevDeviceId, + li = UI.lineIndex, + po = UI.pageOffset, + } +end + +function Navigation.goBack() + if #Navigation.stack > 0 then + local entry = Navigation.stack[#Navigation.stack] + Navigation.stack[#Navigation.stack] = nil + return entry + end + return nil +end + +function Navigation.reset() + Navigation.stack = {} +end + +-- ============================================================================ +-- Protocol Module: CRSF constants, parsing, and field operations +-- (Ported from expresslrs.lua with concat polyfill and collectgarbage) +-- ============================================================================ + +Protocol = { + -- EdgeTX module type for CRSF/ELRS + MODULE_TYPE_CROSSFIRE = 5, + + -- CRSF Field Type Constants + CRSF = { + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + UINT32 = 4, + INT32 = 5, + UINT64 = 6, + INT64 = 7, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + -- Internal/extended types (not in official CRSF protocol) + DEVICE = 15, + DEVICE_FOLDER = 16, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- ELRS flags + ELRS_FLAGS_STATUS_MASK = 0x03, + ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, + + -- Command steps + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, + }, + + -- Handlers dispatch table (populated after function definitions) + handlers = {}, + + -- Device identity + deviceId = 0xEE, + handsetId = 0xEF, + deviceName = nil, + deviceIsELRS_TX = nil, + + -- Fields collection + fields = {}, + fieldsCount = 0, + fieldPopup = nil, + + -- Devices collection + devices = {}, + devicesRefreshTimeout = 50, + + -- Status/flags + elrsFlags = 0, + elrsFlagsInfo = "", + receivedPackets = nil, + lostPackets = nil, + + -- Protocol timing + linkstatTimeout = 100, + + -- Communication state + fieldTimeout = 0, + fieldChunk = 0, + fieldData = nil, + loadQueue = {}, + expectChunksRemain = -1, + backgroundLoading = false, + + -- Debounce: deferred saves for continuous controls + DEBOUNCE_SAVE_DELAY = 30, + pendingSaves = {}, + + -- Connection transition tracking + wasConnected = false, +} + +-- Reset Protocol state +function Protocol.reset() + Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER + Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA + Protocol.deviceName = nil + Protocol.deviceIsELRS_TX = nil + + Protocol.fields = {} + Protocol.fieldsCount = 0 + Protocol.fieldPopup = nil + + Protocol.devices = {} + Protocol.devicesRefreshTimeout = 50 + + Protocol.elrsFlags = 0 + Protocol.elrsFlagsInfo = "" + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + + Protocol.linkstatTimeout = 100 + + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + Protocol.expectChunksRemain = -1 + Protocol.backgroundLoading = false + Protocol.pendingSaves = {} + Protocol.wasConnected = false +end + +-- Default telemetry wrappers (replaced by setMock in simulator) +function Protocol.pop() + return crossfireTelemetryPop() +end + +function Protocol.push(command, data) + return crossfireTelemetryPush(command, data) +end + +function Protocol.isConnected() + return bit32.btest(Protocol.elrsFlags, 1) +end + +function Protocol.fieldResponseTimeout() + return Protocol.deviceIsELRS_TX and 50 or 500 +end + +function Protocol.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +function Protocol.setDevice(device) + if not device then + return false + end + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fldcnt then + return false + end + + Protocol.deviceId = device.id + Protocol.elrsFlags = 0 + Protocol.deviceName = device.name + Protocol.fieldsCount = device.fldcnt + Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + + Protocol.allocateFields() + Protocol.reloadAllFields() + return true +end + +-- ============================================================================ +-- Protocol: Field management functions +-- ============================================================================ + +function Protocol.allocateFields() + Protocol.fields = {} + Protocol.fields[0] = {} + for i = 1, Protocol.fieldsCount do + Protocol.fields[i] = {} + end +end + +function Protocol.isFolderLoaded(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return false + end + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if not child or not child.name or child.nameStale then + return false + end + end + return true +end + +function Protocol.getFolderLoadProgress(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return nil + end + local total = #folder.children + local loaded = 0 + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name then + loaded = loaded + 1 + end + end + return loaded, total +end + +function Protocol.reloadAllFields() + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + Protocol.loadQueue[1] = 0 +end + +function Protocol.getFieldsInFolder(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return {} + end + local result = {} + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name and not child.hidden then + result[#result + 1] = child + end + end + return result +end + +function Protocol.getDevice(id) + for _, device in ipairs(Protocol.devices) do + if device.id == id then + return device + end + end +end + +function Protocol.reloadCurField(field) + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id +end + +function Protocol.loadFolderChildren(folderId) + local folder = Protocol.fields[folderId] + if not folder or not folder.children then + return + end + for i = #folder.children, 1, -1 do + local childId = folder.children[i] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +function Protocol.startBackgroundLoad() + Protocol.backgroundLoading = true + for i = 1, #Protocol.fields do + local field = Protocol.fields[i] + if field.type == Protocol.CRSF.FOLDER and field.children then + for j = #field.children, 1, -1 do + local childId = field.children[j] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- ============================================================================ +-- Protocol: Field data helpers +-- ============================================================================ + +function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) + local r = last or (isOpts and {}) + local optParts = {} + local vcnt = 0 + repeat + local b = data[offset] + offset = offset + 1 + + if not last then + if r and (b == 59 or b == 0) then + r[#r + 1] = tableConcat(optParts) + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + elseif b ~= 0 then + optParts[#optParts + 1] = ({ + [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), + [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) + })[b] or string.char(b) + end + end + until b == 0 + + return (r or tableConcat(optParts)), offset, vcnt +end + +function Protocol.fieldGetValue(data, offset, size) + local result = 0 + for i = 0, size - 1 do + result = bit32.lshift(result, 8) + data[offset + i] + end + return result +end + +-- ============================================================================ +-- Protocol: Field load functions +-- ============================================================================ + +local function fieldUnsignedLoad(field, data, offset, size, unitoffset) + field.value = Protocol.fieldGetValue(data, offset, size) + field.min = Protocol.fieldGetValue(data, offset + size, size) + field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) + field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + if size ~= 1 then + field.size = size + end +end + +local function fieldUnsignedToSigned(field, size) + local bandval = bit32.lshift(0x80, (size - 1) * 8) + field.value = field.value - bit32.band(field.value, bandval) * 2 + field.min = field.min - bit32.band(field.min, bandval) * 2 + field.max = field.max - bit32.band(field.max, bandval) * 2 +end + +local function fieldSignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedToSigned(field, size) + field.size = -size +end + +function Protocol.fieldIntLoad(field, data, offset) + local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad + return loadFn(field, data, offset, math.floor(field.type / 2) + 1) +end + +function Protocol.fieldFloatLoad(field, data, offset) + fieldSignedLoad(field, data, offset, 4, 21) + field.prec = data[offset + 16] + if field.prec > 3 then + field.prec = 3 + end + field.step = Protocol.fieldGetValue(data, offset + 17, 4) + field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit + field.prec = 10 ^ field.prec +end + +function Protocol.fieldTextSelLoad(field, data, offset) + local vcnt + local cached = field.dirty == nil and field.values + field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) + if not cached then + field.disabled = vcnt <= 1 + end + field.value = data[offset] + field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + field.dirty = nil +end + +function Protocol.fieldStringLoad(field, data, offset) + field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) + if #data >= offset then + field.maxlen = data[offset] + end +end + +function Protocol.fieldCommandLoad(field, data, offset) + field.status = data[offset] + field.timeout = data[offset + 1] + field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) + if field.status == Protocol.CRSF.CMD_IDLE then + Protocol.fieldPopup = nil + end +end + +function Protocol.fieldFolderLoad(field, data, offset) + field.children = {} + while data[offset] and data[offset] ~= 0xFF do + field.children[#field.children + 1] = data[offset] + offset = offset + 1 + end +end + +-- ============================================================================ +-- Protocol: Field save functions +-- ============================================================================ + +function Protocol.fieldIntSave(field) + local value = field.value + local size = field.size or 1 + if size < 0 then + size = -size + if value < 0 then + value = bit32.lshift(0x100, (size - 1) * 8) + value + end + end + + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + for i = size - 1, 0, -1 do + frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 + end + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + +-- ============================================================================ +-- Protocol: Related fields reload +-- ============================================================================ + +function Protocol.reloadParentFolder(field) + if field.parent and Protocol.fields[field.parent] then + Protocol.fields[field.parent].nameStale = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent + local minTimeout = getTime() + Protocol.fieldResponseTimeout() + if Protocol.fieldTimeout < minTimeout then + Protocol.fieldTimeout = minTimeout + end + end +end + +function Protocol.debounceSave(field) + Protocol.pendingSaves[field.id] = { field = field, timeout = getTime() + Protocol.DEBOUNCE_SAVE_DELAY } +end + +function Protocol.flushPendingSaves() + for id, ps in pairs(Protocol.pendingSaves) do + Protocol.pendingSaves[id] = nil + Protocol.fieldIntSave(ps.field) + Protocol.reloadParentFolder(ps.field) + end +end + +function Protocol.reloadRelatedFields(field) + Protocol.reloadParentFolder(field) + + for fieldId = Protocol.fieldsCount, 1, -1 do + local sibling = Protocol.fields[fieldId] + local siblingType = sibling.type or 99 + if fieldId ~= field.id + and sibling.parent == field.parent + and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then + sibling.dirty = true + sibling.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId + end + end + + field.dirty = true + field.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id + Protocol.fieldTimeout = getTime() + 20 + Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 +end + +function Protocol.handleCommandSave(field) + Protocol.reloadCurField(field) + + if field.status ~= nil then + if field.status < Protocol.CRSF.CMD_CONFIRMED then + field.status = Protocol.CRSF.CMD_CLICK + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) + Protocol.fieldPopup = field + Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE + Protocol.fieldTimeout = getTime() + field.timeout + end + end +end + +function Protocol.commandConfirm() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED + end +end + +function Protocol.commandCancel() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.fieldPopup = nil + end +end + +-- ============================================================================ +-- Protocol: Handlers dispatch table +-- ============================================================================ + +Protocol.handlers = { + [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT32 + 1] = nil, + [Protocol.CRSF.INT32 + 1] = nil, + [Protocol.CRSF.UINT64 + 1] = nil, + [Protocol.CRSF.INT64 + 1] = nil, + [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, +} + +-- ============================================================================ +-- Protocol: CRSF message parsing +-- ============================================================================ + +function Protocol.parseDeviceInfoMessage(data) + local id = data[2] + local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) + local device = Protocol.getDevice(id) + local isNew = (device == nil) + if isNew then + device = { id = id } + Protocol.devices[#Protocol.devices + 1] = device + end + device.name = newName + device.fldcnt = data[offset + 12] + device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID + + local shouldChangeDevice = (Protocol.deviceId == id) + return { shouldChangeDevice = shouldChangeDevice, deviceId = id, isNewDevice = isNew } +end + +function Protocol.parseParameterInfoMessage(data) + local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return false + end + local field = Protocol.fields[fieldId] + local chunksRemain = data[4] + if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then + return false + end + + local offset + if chunksRemain > 0 or Protocol.fieldChunk > 0 then + Protocol.fieldData = Protocol.fieldData or {} + for i = 5, #data do + Protocol.fieldData[#Protocol.fieldData + 1] = data[i] + data[i] = nil + end + offset = 1 + else + Protocol.fieldData = data + offset = 5 + end + + if chunksRemain > 0 then + Protocol.fieldChunk = Protocol.fieldChunk + 1 + Protocol.expectChunksRemain = chunksRemain - 1 + return false + else + Protocol.loadQueue[#Protocol.loadQueue] = nil + + if #Protocol.fieldData > (offset + 2) then + field.id = fieldId + field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil + field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil + local cachedName = (not field.nameStale) and field.name or nil + field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) + field.nameStale = nil + local handler = Protocol.handlers[field.type + 1] + if handler and handler.load then + handler.load(field, Protocol.fieldData, offset) + end + if field.min == 0 then + field.min = nil + end + if field.max == 0 then + field.max = nil + end + + if field.type == Protocol.CRSF.FOLDER and field.children + and (fieldId == 0 or Protocol.backgroundLoading) then + for i = #field.children, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] + end + end + end + + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + + return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 + end +end + +function Protocol.parseElrsInfoMessage(data) + if data[2] ~= Protocol.deviceId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return + end + + Protocol.lostPackets = data[3] + Protocol.receivedPackets = (data[4] * 256) + data[5] + local newFlags = data[6] + Protocol.elrsFlags = newFlags + Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) +end + +function Protocol.parseElrsV1Message(data) + if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then + return + end + Protocol.fieldPopup = { id = 0, status = Protocol.CRSF.CMD_EXECUTING, timeout = 0xFF, info = "ERROR: 1.x firmware" } + Protocol.fieldTimeout = getTime() + 0xFFFF +end + +-- ============================================================================ +-- Protocol: Main CRSF communication loop +-- ============================================================================ + +function Protocol.poll() + local command, data + local deviceInfoResult = nil + + repeat + command, data = Protocol.pop() + if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then + deviceInfoResult = Protocol.parseDeviceInfoMessage(data) + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + Protocol.parseParameterInfoMessage(data) + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + elseif Protocol.fieldPopup then + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then + Protocol.parseElrsV1Message(data) + elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then + Protocol.parseElrsInfoMessage(data) + end + until command == nil + + -- Auto-discover other devices when link transitions to connected + local connected = Protocol.isConnected() + if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + Protocol.devicesRefreshTimeout = getTime() + 100 + end + Protocol.wasConnected = connected + + local time = getTime() + -- Flush any debounced saves whose timer has expired + for id, ps in pairs(Protocol.pendingSaves) do + if time > ps.timeout then + Protocol.pendingSaves[id] = nil + Protocol.fieldIntSave(ps.field) + Protocol.reloadParentFolder(ps.field) + end + end + + if Protocol.fieldPopup then + if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) + Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout + end + elseif time > Protocol.devicesRefreshTimeout and #Protocol.devices == 0 then + Protocol.devicesRefreshTimeout = time + 100 + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + elseif time > Protocol.linkstatTimeout then + if Protocol.deviceIsELRS_TX then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + else + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + end + Protocol.linkstatTimeout = time + 100 + elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then + if #Protocol.loadQueue > 0 then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) + Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() + else + Protocol.backgroundLoading = false + end + end + + return { deviceInfo = deviceInfoResult } +end + +-- ============================================================================ +-- UI Module: BW LCD rendering (extracted from elrs.lua) +-- ============================================================================ + +UI = { + -- Cursor/selection state + lineIndex = 1, + pageOffset = 0, + edit = nil, + + -- Visible field list (rebuilt on invalidate) + visibleFields = nil, + + -- Layout constants (set in UI.init) + COL1 = 0, + COL2 = 70, + maxLineIndex = 6, + textSize = 8, + textYoffset = 3, + + -- Redraw state + forceRedraw = true, + folderWasReady = false, + + -- Warning flashing + titleShowWarn = nil, + titleShowWarnTimeout = 100, + + -- Command popup spinner + commandRunningIndicator = 1, +} + +function UI.invalidate() + UI.forceRedraw = true + UI.visibleFields = nil +end + +-- ============================================================================ +-- UI: Initialization (BW-only LCD setup from elrs.lua setLCDvar) +-- ============================================================================ + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + else + UI.COL2 = 70 + end + if LCD_H == 96 then + UI.maxLineIndex = 9 + else + UI.maxLineIndex = 6 + end + UI.COL1 = 0 + UI.textYoffset = 3 + UI.textSize = 8 + + -- Determine popupConfirmation argument count + local _, _, major = getVersion() + if major ~= 1 then + popupCompat = popupConfirmation + else + popupCompat = function(t, m, e) + return popupConfirmation(t, e) + end + end +end + +-- ============================================================================ +-- UI: Build visible field list for current navigation state +-- ============================================================================ + +function UI.buildVisibleFields() + local currentFolder = Navigation.getCurrent() + local vf = {} + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + -- Device entries + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.CRSF.DEVICE } + end + end + else + -- Real fields in current folder + local fields = Protocol.getFieldsInFolder(currentFolder) + for _, field in ipairs(fields) do + vf[#vf + 1] = field + end + + -- "Other Devices" entry at root with multiple devices + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + vf[#vf + 1] = { name = "Other Devices", type = Protocol.CRSF.DEVICE_FOLDER } + end + end + + UI.visibleFields = vf +end + +function UI.getField(line) + if not UI.visibleFields then + UI.buildVisibleFields() + end + return UI.visibleFields[line] +end + +function UI.getFieldCount() + if not UI.visibleFields then + UI.buildVisibleFields() + end + return #UI.visibleFields +end + +-- Total selectable rows: all fields + the back/exit widget +function UI.getSelectableCount() + return UI.getFieldCount() + 1 +end + +-- Whether the cursor is on the back/exit widget row +function UI.isOnBackExit() + return UI.lineIndex > UI.getFieldCount() +end + +-- The label for the back/exit widget +function UI.getBackExitLabel() + if Navigation.isAtRoot() then + return "-- EXIT (" .. VERSION .. ") --" + else + return "----BACK----" + end +end + +-- ============================================================================ +-- UI: Field value increment (from elrs.lua incrField, expresslrs.lua incrField) +-- ============================================================================ + +function UI.incrField(step) + local field = UI.getField(UI.lineIndex) + if not field then + return + end + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until (newval == min or newval == max) +end + +-- ============================================================================ +-- UI: Field selection navigation (from elrs.lua selectField) +-- ============================================================================ + +function UI.selectField(step) + local count = UI.getSelectableCount() + if count == 0 then + return + end + local fieldCount = UI.getFieldCount() + local newLineIndex = UI.lineIndex + repeat + newLineIndex = newLineIndex + step + if newLineIndex <= 0 then + newLineIndex = count + elseif newLineIndex > count then + newLineIndex = 1 + UI.pageOffset = 0 + end + -- Back/exit row is always selectable; for fields, skip unnamed ones + if newLineIndex > fieldCount then + break + end + local field = UI.getField(newLineIndex) + if field and field.name then + break + end + until newLineIndex == UI.lineIndex + UI.lineIndex = newLineIndex + if UI.lineIndex > UI.maxLineIndex + UI.pageOffset then + UI.pageOffset = UI.lineIndex - UI.maxLineIndex + elseif UI.lineIndex <= UI.pageOffset then + UI.pageOffset = UI.lineIndex - 1 + end +end + +-- ============================================================================ +-- UI: BW field display functions (from elrs.lua) +-- ============================================================================ + +local function fieldIntDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value .. field.unit, attr) +end + +local function fieldFloatDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec), attr) +end + +local function fieldTextSelDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.values[field.value + 1] or "ERR", attr) + lcd.drawText(lcd.getLastPos(), y, field.unit, 0) +end + +local function fieldStringDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value or "", attr) +end + +local function fieldFolderDisplay(field, y, attr) + lcd.drawText(UI.COL1, y, "> " .. field.name, attr + BOLD) +end + +local function fieldCommandDisplay(field, y, attr) + lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) +end + +-- Display handler table: maps field type to display function +local displayHandlers = {} +displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay +displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay +displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay +displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay +displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay +displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay + +-- ============================================================================ +-- UI: Title bar drawing (from elrs.lua lcd_title_bw) +-- ============================================================================ + +function UI.drawTitle() + local barHeight = 9 + local goodBadPkt = "" + if Protocol.receivedPackets then + local state = Protocol.isConnected() and "C" or "-" + goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if not UI.titleShowWarn then + lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) + lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight - 1, SOLID, INVERS) + end + + if loaded and total and total > 0 and loaded < total then + lcd.drawFilledRectangle(UI.COL2, 0, LCD_W, barHeight, GREY_DEFAULT) + lcd.drawGauge(0, 0, UI.COL2, barHeight, loaded, total, 0) + else + lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) + if UI.titleShowWarn then + lcd.drawText(UI.COL1, 1, Protocol.elrsFlagsInfo, INVERS) + else + lcd.drawText(UI.COL1, 1, Protocol.deviceName or "Searching...", INVERS) + end + end +end + +-- ============================================================================ +-- UI: Warning display (from elrs.lua lcd_warn) +-- ============================================================================ + +function UI.drawWarning() + lcd.drawText(UI.COL1, UI.textSize * 2, "Error:") + lcd.drawText(UI.COL1, UI.textSize * 3, Protocol.elrsFlagsInfo) + lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER) +end + +-- ============================================================================ +-- UI: Event handling (from elrs.lua handleDevicePageEvent, adapted for modules) +-- ============================================================================ + +function UI.handleEvent(event) + if UI.getSelectableCount() == 0 then + return + end + + if event == EVT_VIRTUAL_EXIT then + if UI.edit then + UI.edit = nil + local field = UI.getField(UI.lineIndex) + if field and field.id then + Protocol.reloadCurField(field) + end + else + App.handleBack() + end + elseif event == EVT_VIRTUAL_ENTER then + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + -- Dismiss critical warning + Protocol.elrsFlags = 0 + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + elseif UI.isOnBackExit() then + if Navigation.isAtRoot() then + App.shouldExit = true + else + App.handleBack() + end + else + local field = UI.getField(UI.lineIndex) + if field and field.name then + local ft = field.type + + if ft == Protocol.CRSF.FOLDER then + App.openFolder(field.id, field.name) + elseif ft == Protocol.CRSF.DEVICE_FOLDER then + App.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + elseif ft == Protocol.CRSF.DEVICE then + App.changeDevice(field.id) + elseif ft == Protocol.CRSF.COMMAND then + Protocol.handleCommandSave(field) + elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then + -- Editable value fields + UI.edit = not UI.edit + if not UI.edit then + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end + end + end + end + elseif UI.edit then + if event == EVT_VIRTUAL_NEXT then + UI.incrField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.incrField(-1) + end + else + if event == EVT_VIRTUAL_NEXT then + UI.selectField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.selectField(-1) + end + end +end + +-- ============================================================================ +-- UI: Main page rendering (from elrs.lua runDevicePage) +-- ============================================================================ + +function UI.drawPage(event) + UI.handleEvent(event) + + lcd.clear() + UI.drawTitle() + + -- Show "Other Devices" folder by checking device count + -- (handled via visibleFields list) + + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + UI.drawWarning() + else + local totalCount = UI.getSelectableCount() + for y = 1, UI.maxLineIndex + 1 do + local idx = UI.pageOffset + y + if idx > totalCount then + break + end + local yPos = y * UI.textSize + UI.textYoffset + local isSelected = (UI.lineIndex == idx) + local attr = isSelected and ((UI.edit and BLINK or 0) + INVERS) or 0 + + if idx > UI.getFieldCount() then + -- Back/exit widget row + lcd.drawText(10, yPos, "[" .. UI.getBackExitLabel() .. "]", attr + BOLD) + else + local field = UI.getField(idx) + if field and field.name then + local ft = field.type + -- Draw field name for value/info fields (not folder, command, synthetic) + if ft < Protocol.CRSF.FOLDER or ft == Protocol.CRSF.INFO then + lcd.drawText(UI.COL1, yPos, field.name, 0) + end + -- Draw field value/display + local displayFn = displayHandlers[ft] + if displayFn then + displayFn(field, yPos, attr) + end + end + end + end + end +end + +-- ============================================================================ +-- UI: Command popup rendering (from elrs.lua runPopupPage) +-- ============================================================================ + +function UI.drawPopup(event) + if event == EVT_VIRTUAL_EXIT then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.fieldTimeout = getTime() + 200 + end + + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + popupCompat(Protocol.fieldPopup.info, "Stopped!", event) + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + local result = popupCompat(Protocol.fieldPopup.info, "PRESS [OK] to confirm", event) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "OK" then + Protocol.commandConfirm() + elseif result == "CANCEL" then + Protocol.fieldPopup = nil + end + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if Protocol.fieldChunk == 0 then + UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 + end + local result = popupCompat( + Protocol.fieldPopup.info .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", + "Press [RTN] to exit", + event) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "CANCEL" then + Protocol.commandCancel() + end + end +end + +-- ============================================================================ +-- No Module screen (from elrs.lua checkCrsfModule error display) +-- ============================================================================ + +local function drawNoModule() + lcd.clear() + local y = 0 + lcd.drawText(2, y, " No ExpressLRS", MIDSIZE) + y = y + (UI.textSize * 2) - 2 + local msgs = { + " Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + " also set Internal RF to", + " CRSF in SYS->Hardware", + } + for i, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + UI.textSize + if i == 3 then + lcd.drawLine(0, y, LCD_W, y, SOLID, INVERS) + y = y + 2 + end + end +end + +-- ============================================================================ +-- Mock data for simulator +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + Protocol.pop = mock.pop + Protocol.push = mock.push + Protocol.hasCrsfModule = function() + return mock.moduleFound + end +end + +-- ============================================================================ +-- Init +-- ============================================================================ + +local function init() + UI.init() + setMock() +end + +-- ============================================================================ +-- Run (main coordinator) +-- ============================================================================ + +local function run(event, touchState) + if event == nil then + return 2 + end + + -- Check for CRSF module + if not App.checkCrsfModule() then + drawNoModule() + return 0 + end + + -- CRSF polling + local pollResult = Protocol.poll() + + -- Handle device info update + if pollResult.deviceInfo then + if pollResult.deviceInfo.shouldChangeDevice then + App.changeDevice(pollResult.deviceInfo.deviceId) + end + if pollResult.deviceInfo.isNewDevice then + UI.invalidate() + elseif Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES then + UI.invalidate() + end + end + + -- Warning flashing timer + local time = getTime() + if time > UI.titleShowWarnTimeout then + UI.titleShowWarn = (Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarnTimeout = time + 100 + UI.forceRedraw = true + end + + -- Folder ready detection + GC + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + collectgarbage("collect") + UI.invalidate() + if currentFolder == nil and not Protocol.backgroundLoading then + Protocol.startBackgroundLoad() + end + end + UI.folderWasReady = folderReady + + -- Force redraw during loading to show progress bar + if #Protocol.loadQueue > 0 then + UI.forceRedraw = true + end + + -- Render: command popup or normal page + if Protocol.fieldPopup ~= nil then + UI.drawPopup(event) + elseif event ~= 0 or UI.forceRedraw or UI.edit then + UI.drawPage(event) + UI.forceRedraw = false + end + + if App.shouldExit then + return 2 + end + + return 0 +end + +-- ============================================================================ +-- Return +-- ============================================================================ + +return { init = init, run = run } diff --git a/blackwhite/mockup/README.md b/blackwhite/mockup/README.md deleted file mode 100644 index c7a0597..0000000 --- a/blackwhite/mockup/README.md +++ /dev/null @@ -1,10 +0,0 @@ -This file is for development purposes only and provides field definitions for filling the screen in the OpenTX Companion Simulator. Users do not need this file on their handset's SD card! - -### Using -Copy the entire mockup directory into the same directory as `elrsV2.lua` on your hard drive where you've set the OpenTX Companion "SD Structure Path". The SD structure path should look like this: -``` -SCRIPTS/TOOLS/elrsV2.lua -SCRIPTS/TOOLS/mockup/elrsmock.lua -SCRIPTS/TOOLS/mockup/README.md <- this file -``` -When you execute elrsV2.lua, the screen will be populated with fake ELRS Lua config fields. \ No newline at end of file diff --git a/blackwhite/mockup/elrsmock.lua b/blackwhite/mockup/elrsmock.lua deleted file mode 100644 index 73860c9..0000000 --- a/blackwhite/mockup/elrsmock.lua +++ /dev/null @@ -1,24 +0,0 @@ -return { - {name='Packet Rate', id=0, type=9, values={'250(-108dBm)','500(-105dBm)'}, value=1, unit='Hz'}, - {name='Telem Ratio', id=1, type=9, values={'Off','1:128'}, value=1, unit=''}, - {name='Switch Mode', id=2, type=9, values={'Hybrid','Wide'}, value=1, unit=''}, - {name='Model Match', id=3, type=9, values={'Off',''}, grey=true, value=0, unit='(ID:1)'}, - {name='TX Power', id=4, type=11}, - {name='Max Power', id=5, type=9, parent=4, values={'10','25','50'}, value=2, unit='mW'}, - {name='Dynamic', id=6, type=9, parent=4, values={'Off','On','AUX9'}, value=1, unit=''}, - {name='Fan Thresh', id=7, type=9, parent=4, values={'10mW','25mW','50mW', '250mW'}, value=3, unit=''}, - {name='VTX Administrator', id=8, type=11}, - {name='Band', id=9, type=9, parent=8, values={'Off', 'A', 'B', 'F', 'R', 'L'}, value=0, unit=''}, - {name='Channel', id=10, type=0, parent=8, value=1, step=1, min=1, max=8, unit=''}, - {name='Pwr Lvl', id=11, type=9, parent=8, values={'-', '1', '2', '3' }, value=0, unit=''}, - {name='Pitmode', id=12, type=9, parent=8, values={'Off', 'On'}, value=0, unit=''}, - {name='Send VTx', id=13, type=13, parent=8}, - {name='Bind', id=14, type=13}, - {name='Wifi Update', id=15, type=13}, - {name='BLE Joystick', id=16, type=13}, - {name='master', id=17, type=16, value='f00fcb'}, - {name='Float Tst', id=18, type=8, value=-15, step=5, prec=1000, min=-50, max=50, unit='flt', fmt='%.3fflt'}, - - {name="----BACK----", type=14, parent=255}, - {name="----EXIT----", type=14, exit = true} -}, "0/500 C", "ExpressLRS TX" \ No newline at end of file diff --git a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua similarity index 98% rename from color/SCRIPTS/CRSFSimulator/csrfsimulator.lua rename to global/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 6481b36..10dceec 100644 --- a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -103,6 +103,30 @@ local queueHead = 1 local deferredQueue = {} local deferredReady = false +-- BW/FreedomTX compatibility: provide a local analogue to table.remove(). +-- Supports remove(tbl) and remove(tbl, idx) semantics. +local function tableRemove(tbl, idx) + if table and table.remove then + return table.remove(tbl, idx) + end + + local n = #tbl + local pos = idx + if pos == nil then + pos = n + end + if pos < 1 or pos > n then + return nil + end + + local removed = tbl[pos] + for i = pos, n - 1 do + tbl[i] = tbl[i + 1] + end + tbl[n] = nil + return removed +end + -- Deferred folder name updates simulate the firmware event loop gap: -- PARAMETER_WRITE callbacks set config values immediately, but -- updateFolderNames() runs on the NEXT event loop iteration. @@ -136,7 +160,7 @@ local function queuePop() -- Serve deferred packets only after a nil has been returned (next poll cycle) if deferredReady and #deferredQueue > 0 then - local pkt = table.remove(deferredQueue, 1) + local pkt = tableRemove(deferredQueue, 1) return pkt.command, pkt.data end From 690317109e92495d09588e76c5a1bde72c844411 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 21:06:43 +0200 Subject: [PATCH 012/218] remove old one --- blackwhite/elrs.lua | 958 -------------------------------------------- 1 file changed, 958 deletions(-) delete mode 100755 blackwhite/elrs.lua diff --git a/blackwhite/elrs.lua b/blackwhite/elrs.lua deleted file mode 100755 index 75f672a..0000000 --- a/blackwhite/elrs.lua +++ /dev/null @@ -1,958 +0,0 @@ --- TNS|ExpressLRS|TNE ----- ######################################################################### ----- # # ----- # Copyright (C) OpenTX, adapted for ExpressLRS # ------# # ----- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ----- # # ----- ######################################################################### -local EXITVER = "-- EXIT (Lua r16) --" -local deviceId = 0xEE -local handsetId = 0xEF -local deviceName = nil -local lineIndex = 1 -local pageOffset = 0 -local edit = nil -local fieldPopup -local fieldTimeout = 0 -local loadQ = {} -local fieldChunk = 0 -local fieldData = nil -local fields = {} -local devices = {} -local goodBadPkt = "" -local elrsFlags = 0 -local elrsFlagsInfo = "" -local fields_count = 0 -local devicesRefreshTimeout = 50 -local currentFolderId = nil -local commandRunningIndicator = 1 -local expectChunksRemain = -1 -local deviceIsELRS_TX = nil -local linkstatTimeout = 100 -local titleShowWarn = nil -local titleShowWarnTimeout = 100 -local exitscript = 0 - -local COL1 -local COL2 -local maxLineIndex -local textYoffset -local textSize -local barTextSpacing - -local function allocateFields() - -- fields table is real fields, then the Other Devices item, then devices, then Exit/Back - fields = {} - for i=1, fields_count do - fields[i] = { } - end - fields[#fields+1] = {id=fields_count+1, name="Other Devices", parent=255, type=16} - fields[#fields+1] = {name=EXITVER, type=14} -end - -local function createDeviceFields() -- put other devices in the field list - -- move back button to the end of the list, so it will always show up at the bottom. - fields[fields_count + #devices + 2] = fields[#fields] - for i=1, #devices do - local parent = (devices[i].id == deviceId) and 255 or (fields_count+1) - fields[fields_count + 1 + i] = {id=devices[i].id, name=devices[i].name, parent=parent, type=15} - end -end - -local function reloadAllField() - fieldTimeout = 0 - fieldChunk = 0 - fieldData = nil - -- loadQ is actually a stack - loadQ = {} - for fieldId = fields_count, 1, -1 do - loadQ[#loadQ+1] = fieldId - end -end - -local function getField(line) - local counter = 1 - for i = 1, #fields do - local field = fields[i] - if currentFolderId == field.parent and not field.hidden then - if counter < line then - counter = counter + 1 - else - return field - end - end - end -end - -local function incrField(step) - local field = getField(lineIndex) - local min, max = 0, 0 - if field.type <= 8 then - min = field.min or 0 - max = field.max or 0 - step = (field.step or 1) * step - elseif field.type == 9 then - min = 0 - max = #field.values - 1 - end - - local newval = field.value - repeat - newval = newval + step - if newval < min then - newval = min - elseif newval > max then - newval = max - end - - -- keep looping until a non-blank selection value is found - if field.values == nil or #field.values[newval+1] ~= 0 then - field.value = newval - return - end - until (newval == min or newval == max) -end - --- Select the next or previous editable field -local function selectField(step) - local newLineIndex = lineIndex - local field - repeat - newLineIndex = newLineIndex + step - if newLineIndex <= 0 then - newLineIndex = #fields - elseif newLineIndex == 1 + #fields then - newLineIndex = 1 - pageOffset = 0 - end - field = getField(newLineIndex) - until newLineIndex == lineIndex or (field and field.name) - lineIndex = newLineIndex - if lineIndex > maxLineIndex + pageOffset then - pageOffset = lineIndex - maxLineIndex - elseif lineIndex <= pageOffset then - pageOffset = lineIndex - 1 - end -end - -local function fieldGetStrOrOpts(data, offset, last, isOpts) - -- For isOpts: Split a table of byte values (string) with ; separator into a table - -- Else just read a string until the first null byte - local r = last or (isOpts and {}) - local opt = '' - local vcnt = 0 - repeat - local b = data[offset] - offset = offset + 1 - - if not last then - if r and (b == 59 or b == 0) then -- ';' - r[#r+1] = opt - if opt ~= '' then - vcnt = vcnt + 1 - opt = '' - end - elseif b ~= 0 then - -- On firmwares that have constants defined for the arrow chars, use them in place of - -- the \xc0 \xc1 chars (which are OpenTX-en) - -- Use the table to convert the char, else use string.char if not in the table - opt = opt .. (({ - [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), - [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) - })[b] or string.char(b)) - end - end - until b == 0 - - return (r or opt), offset, vcnt, collectgarbage("collect") -end - -local function getDevice(id) - for _, device in ipairs(devices) do - if device.id == id then - return device - end - end -end - -local function fieldGetValue(data, offset, size) - local result = 0 - for i=0, size-1 do - result = bit32.lshift(result, 8) + data[offset + i] - end - return result -end - -local function reloadCurField() - local field = getField(lineIndex) - fieldTimeout = 0 - fieldChunk = 0 - fieldData = nil - loadQ[#loadQ+1] = field.id -end - --- UINT8/INT8/UINT16/INT16 + FLOAT + TEXTSELECT -local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = fieldGetValue(data, offset, size) - field.min = fieldGetValue(data, offset+size, size) - field.max = fieldGetValue(data, offset+2*size, size) - --field.default = fieldGetValue(data, offset+3*size, size) - field.unit = fieldGetStrOrOpts(data, offset+(unitoffset or (4*size)), field.unit) - -- Only store the size if it isn't 1 (covers most fields / selection) - if size ~= 1 then - field.size = size - end -end - -local function fieldUnsignedToSigned(field, size) - local bandval = bit32.lshift(0x80, (size-1)*8) - field.value = field.value - bit32.band(field.value, bandval) * 2 - field.min = field.min - bit32.band(field.min, bandval) * 2 - field.max = field.max - bit32.band(field.max, bandval) * 2 - --field.default = field.default - bit32.band(field.default, bandval) * 2 -end - -local function fieldSignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedToSigned(field, size) - -- signed ints are INTdicated by a negative size - field.size = -size -end - -local function fieldIntLoad(field, data, offset) - -- Type is U8/I8/U16/I16, use that to determine the size and signedness - local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad - loadFn(field, data, offset, math.floor(field.type / 2) + 1) -end - -local function fieldIntSave(field) - local value = field.value - local size = field.size or 1 - -- Convert signed to 2s complement - if size < 0 then - size = -size - if value < 0 then - value = bit32.lshift(0x100, (size-1)*8) + value - end - end - - local frame = { deviceId, handsetId, field.id } - for i = size-1, 0, -1 do - frame[#frame + 1] = bit32.rshift(value, 8*i) % 256 - end - crossfireTelemetryPush(0x2D, frame) -end - -local function fieldIntDisplay(field, y, attr) - lcd.drawText(COL2, y, field.value .. field.unit, attr) -end - --- -- FLOAT -local function fieldFloatLoad(field, data, offset) - fieldSignedLoad(field, data, offset, 4, 21) - field.prec = data[offset+16] - if field.prec > 3 then - field.prec = 3 - end - field.step = fieldGetValue(data, offset+17, 4) - - -- precompute the format string to preserve the precision - field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit - -- Convert precision to a divider - field.prec = 10 ^ field.prec -end - -local function fieldFloatDisplay(field, y, attr) - lcd.drawText(COL2, y, string.format(field.fmt, field.value / field.prec), attr) -end - --- TEXT SELECTION -local function fieldTextSelLoad(field, data, offset) - local vcnt - local cached = field.nc == nil and field.values - field.values, offset, vcnt = fieldGetStrOrOpts(data, offset, cached, true) - -- 'Disable' the line if values only has one option in the list - if not cached then - field.grey = vcnt <= 1 - end - field.value = data[offset] - -- min max and default (offset+1 to 3) are not used on selections - -- units never uses cache - field.unit = fieldGetStrOrOpts(data, offset+4) - field.nc = nil -- use cache next time -end - -local function fieldTextSelDisplay_color(field, y, attr, color) - local val = field.values[field.value+1] or "ERR" - lcd.drawText(COL2, y, val, attr + color) - local strPix = lcd.sizeText and lcd.sizeText(val) or (10 * #val) - lcd.drawText(COL2 + strPix, y, field.unit, color) -end - -local function fieldTextSelDisplay_bw(field, y, attr) - lcd.drawText(COL2, y, field.values[field.value+1] or "ERR", attr) - lcd.drawText(lcd.getLastPos(), y, field.unit, 0) -end - --- STRING -local function fieldStringLoad(field, data, offset) - field.value, offset = fieldGetStrOrOpts(data, offset) - if #data >= offset then - field.maxlen = data[offset] - end -end - -local function fieldStringDisplay(field, y, attr) - lcd.drawText(COL2, y, field.value, attr) -end - -local function fieldFolderOpen(field) - currentFolderId = field.id - local backFld = fields[#fields] - backFld.name = "----BACK----" - -- Store the lineIndex and pageOffset to return to in the backFld - backFld.li = lineIndex - backFld.po = pageOffset - backFld.parent = currentFolderId - - lineIndex = 1 - pageOffset = 0 -end - -local function fieldFolderDeviceOpen(field) - -- crossfireTelemetryPush(0x28, { 0x00, 0xEA }) --broadcast with standard handset ID to get all node respond correctly - -- Make sure device fields are in the folder when it opens - createDeviceFields() - return fieldFolderOpen(field) -end - -local function fieldFolderDisplay(field,y ,attr) - lcd.drawText(COL1, y, "> " .. field.name, attr + BOLD) -end - -local function fieldCommandLoad(field, data, offset) - field.status = data[offset] - field.timeout = data[offset+1] - field.info = fieldGetStrOrOpts(data, offset+2) - if field.status == 0 then - fieldPopup = nil - end -end - -local function fieldCommandSave(field) - reloadCurField() - - if field.status ~= nil then - if field.status < 4 then - field.status = 1 - crossfireTelemetryPush(0x2D, { deviceId, handsetId, field.id, field.status }) - fieldPopup = field - fieldPopup.lastStatus = 0 - fieldTimeout = getTime() + field.timeout - end - end -end - -local function fieldCommandDisplay(field, y, attr) - lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) -end - -local function fieldBackExec(field) - if field.parent then - lineIndex = field.li or 1 - pageOffset = field.po or 0 - - field.name = EXITVER - field.parent = nil - field.li = nil - field.po = nil - currentFolderId = nil - else - exitscript = 1 - end -end - -local function changeDeviceId(devId) --change to selected device ID - local device = getDevice(devId) - if deviceId == devId and fields_count == device.fldcnt then return end - - deviceId = devId - elrsFlags = 0 - currentFolderId = nil - deviceName = device.name - fields_count = device.fldcnt - deviceIsELRS_TX = device.isElrs and devId == 0xEE or nil -- ELRS and ID is TX module - handsetId = deviceIsELRS_TX and 0xEF or 0xEA -- Address ELRS_LUA vs RADIO_TRANSMITTER - - allocateFields() - reloadAllField() -end - -local function fieldDeviceIdSelect(field) - return changeDeviceId(field.id) -end - -local function parseDeviceInfoMessage(data) - local id = data[2] - local newName, offset = fieldGetStrOrOpts(data, 3) - local device = getDevice(id) - if device == nil then - device = { id = id } - devices[#devices + 1] = device - end - device.name = newName - device.fldcnt = data[offset + 12] - device.isElrs = fieldGetValue(data, offset, 4) == 0x454C5253 -- SerialNumber = 'E L R S' - - if deviceId == id then - changeDeviceId(id) - end - -- DeviceList change while in Other Devices, refresh list - if currentFolderId == fields_count + 1 then - createDeviceFields() - end -end - -local functions = { - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --1 UINT8(0) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --2 INT8(1) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --3 UINT16(2) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --4 INT16(3) - nil, - nil, - nil, - nil, - { load=fieldFloatLoad, save=fieldIntSave, display=fieldFloatDisplay }, --9 FLOAT(8) - { load=fieldTextSelLoad, save=fieldIntSave, display=nil }, --10 SELECT(9) - { load=fieldStringLoad, save=nil, display=fieldStringDisplay }, --11 STRING(10) editing NOTIMPL - { load=nil, save=fieldFolderOpen, display=fieldFolderDisplay }, --12 FOLDER(11) - { load=fieldStringLoad, save=nil, display=fieldStringDisplay }, --13 INFO(12) - { load=fieldCommandLoad, save=fieldCommandSave, display=fieldCommandDisplay }, --14 COMMAND(13) - { load=nil, save=fieldBackExec, display=fieldCommandDisplay }, --15 back/exit(14) - { load=nil, save=fieldDeviceIdSelect, display=fieldCommandDisplay }, --16 device(15) - { load=nil, save=fieldFolderDeviceOpen, display=fieldFolderDisplay }, --17 deviceFOLDER(16) -} - -local function parseParameterInfoMessage(data) - local fieldId = (fieldPopup and fieldPopup.id) or loadQ[#loadQ] - if data[2] ~= deviceId or data[3] ~= fieldId then - fieldData = nil - fieldChunk = 0 - return - end - local field = fields[fieldId] - local chunksRemain = data[4] - -- If no field or the chunksremain changed when we have data, don't continue - if not field or (fieldData and chunksRemain ~= expectChunksRemain) then - return - end - - local offset - -- If data is chunked, copy it to persistent buffer - if chunksRemain > 0 or fieldChunk > 0 then - fieldData = fieldData or {} - for i=5, #data do - fieldData[#fieldData + 1] = data[i] - data[i] = nil - end - offset = 1 - else - -- All data arrived in one chunk, operate directly on data - fieldData = data - offset = 5 - end - - if chunksRemain > 0 then - fieldChunk = fieldChunk + 1 - expectChunksRemain = chunksRemain - 1 - else - -- Field data stream is now complete, process into a field - loadQ[#loadQ] = nil - - if #fieldData > (offset + 2) then - field.id = fieldId - field.parent = (fieldData[offset] ~= 0) and fieldData[offset] or nil - field.type = bit32.band(fieldData[offset+1], 0x7f) - field.hidden = bit32.btest(fieldData[offset+1], 0x80) or nil - field.name, offset = fieldGetStrOrOpts(fieldData, offset+2, field.name) - if functions[field.type+1].load then - functions[field.type+1].load(field, fieldData, offset) - end - if field.min == 0 then field.min = nil end - if field.max == 0 then field.max = nil end - end - - fieldChunk = 0 - fieldData = nil - - -- Return value is if the screen should be updated - -- If deviceId is TX module, then the Bad/Good drives the update; for other - -- devices update each new item. and always update when the queue empties - return deviceId ~= 0xEE or #loadQ == 0 - end -end - -local function parseElrsInfoMessage(data) - if data[2] ~= deviceId then - fieldData = nil - fieldChunk = 0 - return - end - - local badPkt = data[3] - local goodPkt = (data[4]*256) + data[5] - local newFlags = data[6] - -- If flags are changing, reset the warning timeout to display/hide message immediately - if newFlags ~= elrsFlags then - elrsFlags = newFlags - titleShowWarnTimeout = 0 - end - elrsFlagsInfo = fieldGetStrOrOpts(data, 7) - - local state = (bit32.btest(elrsFlags, 1) and "C") or "-" - goodBadPkt = string.format("%u/%u %s", badPkt, goodPkt, state) -end - -local function parseElrsV1Message(data) - if (data[1] ~= 0xEA) or (data[2] ~= 0xEE) then - return - end - - -- local badPkt = data[9] - -- local goodPkt = (data[10]*256) + data[11] - -- goodBadPkt = string.format("%u/%u X", badPkt, goodPkt) - fieldPopup = {id = 0, status = 2, timeout = 0xFF, info = "ERROR: 1.x firmware"} - fieldTimeout = getTime() + 0xFFFF -end - -local function refreshNext(skipPush) - local command, data, forceRedraw - repeat - command, data = crossfireTelemetryPop() - if command == 0x29 then - parseDeviceInfoMessage(data) - elseif command == 0x2B then - if parseParameterInfoMessage(data) then - forceRedraw = true - end - if #loadQ > 0 then - fieldTimeout = 0 -- request next chunk immediately - elseif fieldPopup then - fieldTimeout = getTime() + fieldPopup.timeout - end - elseif command == 0x2D then - parseElrsV1Message(data) - elseif command == 0x2E then - parseElrsInfoMessage(data) - forceRedraw = true - end - until command == nil - - -- Don't even bother with return value, skipPush implies redraw - if skipPush then return end - - local time = getTime() - if fieldPopup then - if time > fieldTimeout and fieldPopup.status ~= 3 then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 6 }) -- lcsQuery - fieldTimeout = time + fieldPopup.timeout - end - elseif time > devicesRefreshTimeout and #devices == 0 then - forceRedraw = true -- handles initial screen draw - devicesRefreshTimeout = time + 100 -- 1s - crossfireTelemetryPush(0x28, { 0x00, 0xEA }) - elseif time > linkstatTimeout then - if deviceIsELRS_TX then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, 0x0, 0x0 }) --request linkstat - else - goodBadPkt = "" - end - linkstatTimeout = time + 100 - elseif time > fieldTimeout and fields_count ~= 0 then - if #loadQ > 0 then - crossfireTelemetryPush(0x2C, { deviceId, handsetId, loadQ[#loadQ], fieldChunk }) - fieldTimeout = time + (deviceIsELRS_TX and 50 or 500) -- 0.5s for local / 5s for remote devices - end - end - - if time > titleShowWarnTimeout then - -- if elrsFlags bit set is bit higher than bit 0 and bit 1, it is warning flags - titleShowWarn = (elrsFlags > 3 and not titleShowWarn) or nil - titleShowWarnTimeout = time + 100 - forceRedraw = true - end - - return forceRedraw -end - -local lcd_title -- holds function that is color/bw version -local function lcd_title_color() - lcd.clear() - - local EBLUE = lcd.RGB(0x43, 0x61, 0xAA) - local EGREEN = lcd.RGB(0x9f, 0xc7, 0x6f) - local EGREY1 = lcd.RGB(0x91, 0xb2, 0xc9) - local EGREY2 = lcd.RGB(0x6f, 0x62, 0x7f) - - -- Field display area (white w/ 2px green border) - lcd.setColor(CUSTOM_COLOR, EGREEN) - lcd.drawRectangle(0, 0, LCD_W, LCD_H, CUSTOM_COLOR) - lcd.drawRectangle(1, 0, LCD_W - 2, LCD_H - 1, CUSTOM_COLOR) - -- title bar - lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, EGREY1) - lcd.drawFilledRectangle(LCD_W - textSize, 0, textSize, barHeight, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, EGREY2) - lcd.drawRectangle(LCD_W - textSize, 0, textSize, barHeight - 1, CUSTOM_COLOR) - lcd.drawRectangle(LCD_W - textSize, 1 , textSize - 1, barHeight - 2, CUSTOM_COLOR) -- left and bottom line only 1px, make it look bevelled - lcd.setColor(CUSTOM_COLOR, BLACK) - if titleShowWarn then - lcd.drawText(COL1 + 1, barTextSpacing, elrsFlagsInfo, CUSTOM_COLOR) - else - lcd.drawText(COL1 + 1, barTextSpacing, deviceName, CUSTOM_COLOR) - lcd.drawText(LCD_W - 5, barTextSpacing, goodBadPkt, RIGHT + BOLD + CUSTOM_COLOR) - end - -- progress bar - if #loadQ > 0 and fields_count > 0 then - local barW = (COL2-4) * (fields_count - #loadQ) / fields_count - lcd.setColor(CUSTOM_COLOR, EBLUE) - lcd.drawFilledRectangle(2, barTextSpacing/2+textSize, barW, barTextSpacing, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, WHITE) - lcd.drawFilledRectangle(2+barW, barTextSpacing/2+textSize, COL2-2-barW, barTextSpacing, CUSTOM_COLOR) - end -end - -local function lcd_title_bw() - lcd.clear() - -- B&W screen - local barHeight = 9 - if not titleShowWarn then - lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) - lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight-1, SOLID, INVERS) - end - - if #loadQ > 0 and fields_count > 0 then - lcd.drawFilledRectangle(COL2, 0, LCD_W, barHeight, GREY_DEFAULT) - lcd.drawGauge(0, 0, COL2, barHeight, fields_count - #loadQ, fields_count, 0) - else - lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) - if titleShowWarn then - lcd.drawText(COL1, 1, elrsFlagsInfo, INVERS) - else - lcd.drawText(COL1, 1, deviceName, INVERS) - end - end -end - -local function lcd_warn() - lcd.drawText(COL1, textSize*2, "Error:") - lcd.drawText(COL1, textSize*3, elrsFlagsInfo) - lcd.drawText(LCD_W/2, textSize*5, "[OK]", BLINK + INVERS + CENTER) -end - -local function reloadRelatedFields(field) - -- Reload the parent folder to update the description - if field.parent then - loadQ[#loadQ+1] = field.parent - fields[field.parent].name = nil - end - - -- Reload all editable fields at the same level as well as the parent item - for fieldId = fields_count, 1, -1 do - -- Skip this field, will be added to end - local fldTest = fields[fieldId] - local fldType = fldTest.type or 99 -- type could be nil if still loading - if fieldId ~= field.id - and fldTest.parent == field.parent - and (fldType < 11 or fldType == 12) then -- ignores FOLDER/COMMAND/devices/EXIT - fldTest.nc = true -- "no cache" the options - loadQ[#loadQ+1] = fieldId - end - end - - -- Reload this field - loadQ[#loadQ+1] = field.id - -- with a short delay to allow the module EEPROM to commit - fieldTimeout = getTime() + 20 - -- Also push the next bad/good update further out - linkstatTimeout = fieldTimeout + 100 -end - -local function handleDevicePageEvent(event) - if #fields == 0 then --if there is no field yet - return - else - if fields[#fields].name == nil then --if back button is not assigned yet, means there is no field yet. - return - end - end - - if event == EVT_VIRTUAL_EXIT then -- Cancel edit / go up a folder / reload all - if edit then - edit = nil - reloadCurField() - else - if currentFolderId == nil and #loadQ == 0 then -- only do reload if we're in the root folder and finished loading - if deviceId ~= 0xEE then - changeDeviceId(0xEE) - else - reloadAllField() - end - crossfireTelemetryPush(0x28, { 0x00, 0xEA }) - else - fieldBackExec(fields[#fields]) - end - end - elseif event == EVT_VIRTUAL_ENTER then -- toggle editing/selecting current field - if elrsFlags > 0x1F then - elrsFlags = 0 - crossfireTelemetryPush(0x2D, { deviceId, handsetId, 0x2E, 0x00 }) - else - local field = getField(lineIndex) - if field and field.name then - -- Editable fields - if not field.grey and field.type < 10 then - edit = not edit - if not edit then - reloadRelatedFields(field) - end - end - if not edit then - if functions[field.type+1].save then - functions[field.type+1].save(field) - end - end - end - end - elseif edit then - if event == EVT_VIRTUAL_NEXT then - incrField(1) - elseif event == EVT_VIRTUAL_PREV then - incrField(-1) - end - else - if event == EVT_VIRTUAL_NEXT then - selectField(1) - elseif event == EVT_VIRTUAL_PREV then - selectField(-1) - end - end -end - --- Main -local function runDevicePage(event) - handleDevicePageEvent(event) - - lcd_title() - - if #devices > 1 then -- show other device folder - fields[fields_count+1].parent = nil - end - if elrsFlags > 0x1F then - lcd_warn() - else - for y = 1, maxLineIndex+1 do - local field = getField(pageOffset+y) - if not field then - break - elseif field.name ~= nil then - local attr = lineIndex == (pageOffset+y) - and ((edit and BLINK or 0) + INVERS) - or 0 - local color = field.grey and COLOR_THEME_DISABLED or 0 - if field.type < 11 or field.type == 12 then -- if not folder, command, or back - lcd.drawText(COL1, y*textSize+textYoffset, field.name, color) - end - if functions[field.type+1].display then - functions[field.type+1].display(field, y*textSize+textYoffset, attr, color) - end - end - end - end -end - -local function popupCompat(t, m, e) - -- Only use 2 of 3 arguments for older platforms - return popupConfirmation(t, e) -end - -local function runPopupPage(event) - if event == EVT_VIRTUAL_EXIT then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 5 }) -- lcsCancel - fieldTimeout = getTime() + 200 -- 2s - end - - if fieldPopup.status == 0 and fieldPopup.lastStatus ~= 0 then -- stopped - popupCompat(fieldPopup.info, "Stopped!", event) - reloadAllField() - fieldPopup = nil - elseif fieldPopup.status == 3 then -- confirmation required - local result = popupCompat(fieldPopup.info, "PRESS [OK] to confirm", event) - fieldPopup.lastStatus = fieldPopup.status - if result == "OK" then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 4 }) -- lcsConfirmed - fieldTimeout = getTime() + fieldPopup.timeout -- we are expecting an immediate response - fieldPopup.status = 4 - elseif result == "CANCEL" then - fieldPopup = nil - end - elseif fieldPopup.status == 2 then -- running - if fieldChunk == 0 then - commandRunningIndicator = (commandRunningIndicator % 4) + 1 - end - local result = popupCompat(fieldPopup.info .. " [" .. string.sub("|/-\\", commandRunningIndicator, commandRunningIndicator) .. "]", "Press [RTN] to exit", event) - fieldPopup.lastStatus = fieldPopup.status - if result == "CANCEL" then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 5 }) -- lcsCancel - fieldTimeout = getTime() + fieldPopup.timeout -- we are expecting an immediate response - fieldPopup = nil - end - end -end - -local function touch2evt(event, touchState) - -- Convert swipe events to normal events Left/Right/Up/Down -> EXIT/ENTER/PREV/NEXT - -- PREV/NEXT are swapped if editing - -- TAP is converted to ENTER - touchState = touchState or {} - return (touchState.swipeLeft and EVT_VIRTUAL_EXIT) - or (touchState.swipeRight and EVT_VIRTUAL_ENTER) - or (touchState.swipeUp and (edit and EVT_VIRTUAL_NEXT or EVT_VIRTUAL_PREV)) - or (touchState.swipeDown and (edit and EVT_VIRTUAL_PREV or EVT_VIRTUAL_NEXT)) - or (event == EVT_TOUCH_TAP and EVT_VIRTUAL_ENTER) -end - -local function setLCDvar() - -- Set the title function depending on if LCD is color, and free the other function and - -- set textselection unit function, use GetLastPost or sizeText - if (lcd.RGB ~= nil) then - lcd_title = lcd_title_color - functions[10].display = fieldTextSelDisplay_color - else - lcd_title = lcd_title_bw - functions[10].display = fieldTextSelDisplay_bw - touch2evt = nil - end - lcd_title_color = nil - lcd_title_bw = nil - fieldTextSelDisplay_bw = nil - fieldTextSelDisplay_color = nil - -- Determine if popupConfirmation takes 3 arguments or 2 - -- if pcall(popupConfirmation, "", "", EVT_VIRTUAL_EXIT) then - -- major 1 is assumed to be FreedomTX - local _, _, major = getVersion() - if major ~= 1 then - popupCompat = popupConfirmation - end - - if (lcd.RGB ~= nil) then - local ver, radio, maj, minor, rev, osname = getVersion() - - if osname ~= nil and osname == "EdgeTX" then - textWidth, textSize = lcd.sizeText("Qg") -- determine standard font height for EdgeTX - else - textSize = 21 -- use this for OpenTX - end - - COL1 = 3 - COL2 = LCD_W/2 - barTextSpacing = 4 - barHeight = textSize + barTextSpacing + barTextSpacing - textYoffset = 2 * barTextSpacing + 2 - maxLineIndex = math.floor(((LCD_H - barHeight - textYoffset) / textSize)) - 1 - else - if LCD_W == 212 then - COL2 = 110 - else - COL2 = 70 - end - if LCD_H == 96 then - maxLineIndex = 9 - else - maxLineIndex = 6 - end - COL1 = 0 - textYoffset = 3 - textSize = 8 - end -end - -local function setMock() - -- Setup fields to display if running in Simulator - local _, rv = getVersion() - if string.sub(rv, -5) ~= "-simu" then return end - local mock = loadScript("mockup/elrsmock.lua") - if mock == nil then return end - fields, goodBadPkt, deviceName = mock() - fields_count = #fields - 1 - loadQ = { fields_count } - deviceIsELRS_TX = true -end - -local function checkCrsfModule() - -- Loop through the modules and look for one set to CRSF (5) - for modIdx = 0, 1 do - local mod = model.getModule(modIdx) - if mod and (mod.Type == nil or mod.Type == 5) then - -- CRSF found, put module type in Loading message - local modDescrip = (mod.Type == nil) and " awaiting" or (modIdx == 0) and " Internal" or " External" - -- Prefix with "Lua rXXX" from between EXITVER parens - deviceName = string.match(EXITVER, "%((.*)%)") .. modDescrip .. " TX..." - checkCrsfModule = nil - return 0 - end - end - - -- No CRSF module found, save an error message for run() - lcd.clear() - local y = 0 - lcd.drawText(2, y, " No ExpressLRS", MIDSIZE) - y = y + (textSize * 2) - 2 - local msgs = { - " Enable a CRSF Internal", - " or External module in", - " Model settings", - " If module is internal", - " also set Internal RF to", - " CRSF in SYS->Hardware", - } - for i, msg in ipairs(msgs) do - lcd.drawText(2, y, msg) - y = y + textSize - if i == 3 then - lcd.drawLine(0, y, LCD_W, y, SOLID, INVERS) - y = y + 2 - end - end - - return 0 -end - --- Init -local function init() - setLCDvar() - setMock() - setLCDvar = nil - setMock = nil -end - --- Main -local function run(event, touchState) - if event == nil then return 2 end - if checkCrsfModule then return checkCrsfModule() end - - event = (touch2evt and touch2evt(event, touchState)) or event - -- If ENTER pressed, skip any pushing this loop to reserve queue for the save command - local forceRedraw = refreshNext(event == EVT_VIRTUAL_ENTER) - - if fieldPopup ~= nil then - runPopupPage(event) - elseif event ~= 0 or forceRedraw or edit then - runDevicePage(event) - end - - return exitscript -end - -return { init=init, run=run } From 815806c2d5f886b9ee25a743b031e14872f9ef15 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 12 Feb 2026 00:56:20 +0200 Subject: [PATCH 013/218] implement slow loading simulator --- .../SCRIPTS/CRSFSimulator/csrfsimulator.lua | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 10dceec..ebb8794 100644 --- a/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -25,6 +25,10 @@ -- Triggers the Model Mismatch warning dialog. -- "armed" TX + RX connected with the "is Armed" warning flag set. -- Shows armed warning in subtitle. +-- "slow_loading" TX + RX connected but PARAMETER_READ responses are +-- delayed by ~2 seconds each. Tests how the UI renders +-- during slow field discovery (e.g. "Loading..." states +-- in minimized widgets, full-screen subtitle updates). -- "no_module" No CRSF module found at all. Triggers the "No Module -- Found" error dialog immediately. local config = { @@ -127,6 +131,12 @@ local function tableRemove(tbl, idx) return removed end +-- Slow loading scenario: time-delayed response queue. +-- PARAMETER_READ responses are held here until their delivery time, then +-- promoted to the main queue so the Lua script sees realistic latency. +local SLOW_LOADING_DELAY_TICKS = 200 -- 2 seconds per field (getTime() at 10ms/tick) +local delayedResponseQueue = {} + -- Deferred folder name updates simulate the firmware event loop gap: -- PARAMETER_WRITE callbacks set config values immediately, but -- updateFolderNames() runs on the NEXT event loop iteration. @@ -739,7 +749,7 @@ local function getElrsFlags() return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then return 0x09 -- connected + armed - elseif config.scenario == "normal" then + elseif config.scenario == "normal" or config.scenario == "slow_loading" then return 0x01 -- connected else return 0x00 -- disconnected @@ -863,7 +873,16 @@ local function mockPush(command, data) param._device = param._device or device local entry = encodeParameterEntry(device, param, chunk, destAddr) param._device = nil -- clean up temporary reference - queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + if config.scenario == "slow_loading" then + -- Delay response to simulate slow OTA field loading + delayedResponseQueue[#delayedResponseQueue + 1] = { + command = CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, + data = entry, + deliverAt = getTime() + SLOW_LOADING_DELAY_TICKS, + } + else + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + end end end return true @@ -925,6 +944,18 @@ local function mockPop() startTime = getTime() end + -- Promote delayed responses whose delivery time has been reached + local now = getTime() + local i = 1 + while i <= #delayedResponseQueue do + if now >= delayedResponseQueue[i].deliverAt then + local entry = tableRemove(delayedResponseQueue, i) + queuePush(entry.command, entry.data) + else + i = i + 1 + end + end + local command, data = queuePop() -- Apply deferred folder name updates once enough real time has elapsed. @@ -980,6 +1011,14 @@ local scenarioTelemetry = { RQly = 99, ANT = 1, RxBt = 15.2, Curr = 12.5, }, + slow_loading = { + -- Same as normal; fields load slowly but telemetry is available + TPWR = 50, RFMD = 7, + ["1RSS"] = -87, ["2RSS"] = -93, + RQly = 99, ANT = 1, + RxBt = 15.2, Curr = 12.5, + FM = "ACRO", Sats = 12, GSpd = 25.3, Alt = 142, + }, } -- Jitter ranges for sensors that fluctuate in real life. From c3dfd38c9dd3ca3e453db0bcd5b45515ba9315ee Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 12 Feb 2026 00:59:39 +0200 Subject: [PATCH 014/218] update crsf sim --- color/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 6481b36..ebb8794 100644 --- a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -25,6 +25,10 @@ -- Triggers the Model Mismatch warning dialog. -- "armed" TX + RX connected with the "is Armed" warning flag set. -- Shows armed warning in subtitle. +-- "slow_loading" TX + RX connected but PARAMETER_READ responses are +-- delayed by ~2 seconds each. Tests how the UI renders +-- during slow field discovery (e.g. "Loading..." states +-- in minimized widgets, full-screen subtitle updates). -- "no_module" No CRSF module found at all. Triggers the "No Module -- Found" error dialog immediately. local config = { @@ -103,6 +107,36 @@ local queueHead = 1 local deferredQueue = {} local deferredReady = false +-- BW/FreedomTX compatibility: provide a local analogue to table.remove(). +-- Supports remove(tbl) and remove(tbl, idx) semantics. +local function tableRemove(tbl, idx) + if table and table.remove then + return table.remove(tbl, idx) + end + + local n = #tbl + local pos = idx + if pos == nil then + pos = n + end + if pos < 1 or pos > n then + return nil + end + + local removed = tbl[pos] + for i = pos, n - 1 do + tbl[i] = tbl[i + 1] + end + tbl[n] = nil + return removed +end + +-- Slow loading scenario: time-delayed response queue. +-- PARAMETER_READ responses are held here until their delivery time, then +-- promoted to the main queue so the Lua script sees realistic latency. +local SLOW_LOADING_DELAY_TICKS = 200 -- 2 seconds per field (getTime() at 10ms/tick) +local delayedResponseQueue = {} + -- Deferred folder name updates simulate the firmware event loop gap: -- PARAMETER_WRITE callbacks set config values immediately, but -- updateFolderNames() runs on the NEXT event loop iteration. @@ -136,7 +170,7 @@ local function queuePop() -- Serve deferred packets only after a nil has been returned (next poll cycle) if deferredReady and #deferredQueue > 0 then - local pkt = table.remove(deferredQueue, 1) + local pkt = tableRemove(deferredQueue, 1) return pkt.command, pkt.data end @@ -715,7 +749,7 @@ local function getElrsFlags() return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then return 0x09 -- connected + armed - elseif config.scenario == "normal" then + elseif config.scenario == "normal" or config.scenario == "slow_loading" then return 0x01 -- connected else return 0x00 -- disconnected @@ -839,7 +873,16 @@ local function mockPush(command, data) param._device = param._device or device local entry = encodeParameterEntry(device, param, chunk, destAddr) param._device = nil -- clean up temporary reference - queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + if config.scenario == "slow_loading" then + -- Delay response to simulate slow OTA field loading + delayedResponseQueue[#delayedResponseQueue + 1] = { + command = CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, + data = entry, + deliverAt = getTime() + SLOW_LOADING_DELAY_TICKS, + } + else + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + end end end return true @@ -901,6 +944,18 @@ local function mockPop() startTime = getTime() end + -- Promote delayed responses whose delivery time has been reached + local now = getTime() + local i = 1 + while i <= #delayedResponseQueue do + if now >= delayedResponseQueue[i].deliverAt then + local entry = tableRemove(delayedResponseQueue, i) + queuePush(entry.command, entry.data) + else + i = i + 1 + end + end + local command, data = queuePop() -- Apply deferred folder name updates once enough real time has elapsed. @@ -956,6 +1011,14 @@ local scenarioTelemetry = { RQly = 99, ANT = 1, RxBt = 15.2, Curr = 12.5, }, + slow_loading = { + -- Same as normal; fields load slowly but telemetry is available + TPWR = 50, RFMD = 7, + ["1RSS"] = -87, ["2RSS"] = -93, + RQly = 99, ANT = 1, + RxBt = 15.2, Curr = 12.5, + FM = "ACRO", Sats = 12, GSpd = 25.3, Alt = 142, + }, } -- Jitter ranges for sensors that fluctuate in real life. From f939f9bb82d4f2dc256e10ca3ae1d5a672d1a1be Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 18:21:03 +0200 Subject: [PATCH 015/218] simplify full screen mode detection --- color/WIDGETS/ELRSTelemetry/loadable.lua | 17 +++++------------ color/WIDGETS/ELRSVTXAdmin/loadable.lua | 23 ++++++++--------------- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/color/WIDGETS/ELRSTelemetry/loadable.lua b/color/WIDGETS/ELRSTelemetry/loadable.lua index 8223877..befa03d 100644 --- a/color/WIDGETS/ELRSTelemetry/loadable.lua +++ b/color/WIDGETS/ELRSTelemetry/loadable.lua @@ -515,7 +515,6 @@ end local wgt = { zone = zone, options = options, - wasFullScreen = false, } function wgt.background() @@ -533,21 +532,15 @@ function wgt.refresh(event, touchState) local tlm = Telemetry.readLink() Telemetry.updateDiversity(tlm.ant) end - - local isFullScreen = lvgl.isFullScreen() - if isFullScreen ~= wgt.wasFullScreen then - wgt.wasFullScreen = isFullScreen - if isFullScreen then - buildFullScreen() - else - WidgetUI.build(wgt.zone, wgt.options) - end - end end function wgt.update(newOptions) wgt.options = newOptions - WidgetUI.build(wgt.zone, wgt.options) + if lvgl.isFullScreen() then + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end end -- Initial build diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/color/WIDGETS/ELRSVTXAdmin/loadable.lua index f83078f..a5efa0e 100644 --- a/color/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/color/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1022,7 +1022,6 @@ end local wgt = { zone = zone, options = options, - wasFullScreen = false, } function wgt.background() @@ -1034,24 +1033,18 @@ end function wgt.refresh(event, touchState) wgt.background() - - local isFullScreen = lvgl.isFullScreen() - if isFullScreen ~= wgt.wasFullScreen then - wgt.wasFullScreen = isFullScreen - if isFullScreen then - if Protocol.isReady() then - VTX.syncDesiredFromState() - end - buildFullScreen() - else - WidgetUI.build(wgt.zone, wgt.options) - end - end end function wgt.update(newOptions) wgt.options = newOptions - WidgetUI.build(wgt.zone, wgt.options) + if lvgl.isFullScreen() then + if Protocol.isReady() then + VTX.syncDesiredFromState() + end + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end end -- Initial build From 8b57959ae1c08ac4d11f52dd804d8aa4a9666f8b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:35:00 +0200 Subject: [PATCH 016/218] simulate CE long strings in TX Power folder --- color/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua index ebb8794..b1f2cec 100644 --- a/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/color/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -408,11 +408,11 @@ local txDevice = { -- TX Power folder { id = 6, parent = 0, type = CRSF.FOLDER, name = "TX Power" }, { id = 7, parent = 6, type = CRSF.TEXT_SELECTION, name = "Max Power", - options = "10;25;50;100;250", value = 4, units = "mW" }, + options = "10/10;25/25;25/50;25/100;25/250;25/500;25/1000;25/2000", value = 3, units = "mW" }, { id = 8, parent = 6, type = CRSF.TEXT_SELECTION, name = "Dynamic", options = "Off;Dyn;AUX9;AUX10;AUX11;AUX12", value = 1, units = "" }, { id = 9, parent = 6, type = CRSF.TEXT_SELECTION, name = "Fan Thresh", - options = "10mW;25mW;50mW;100mW;250mW", value = 3, units = "" }, + options = "10mW;25mW;50mW;100mW;250mW;500mW;1000mW;2000mW;Never", value = 3, units = "" }, -- VTX Administrator folder { id = 10, parent = 0, type = CRSF.FOLDER, name = "VTX Administrator" }, From ef20f803348bdca8eab20277a442cf9790b07971 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:38:04 +0200 Subject: [PATCH 017/218] Migrate to 2 columns folder layout for all radios, except for 3 columns for mk3 and 1 column for small screen radios --- color/SCRIPTS/TOOLS/expresslrs.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index 1d98a2c..0c92b15 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -1260,9 +1260,13 @@ function UI.build() end local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) - -- Narrow screens (e.g. FlySky EL18 portrait, PA01) are too narrow for 3 folders per row - local FOLDERS_PER_ROW = IS_NARROW and 1 or 3 - local folderWidth = math.floor(100 / FOLDERS_PER_ROW) + local FOLDERS_PER_ROW = 2 + if IS_NARROW then + FOLDERS_PER_ROW = 1 -- FlySky EL18 portrait, PA01 (LCD_W < 400) + elseif LCD_W >= 800 then + FOLDERS_PER_ROW = 3 -- TX16S MK3 and other HD screens + end + local folderWidth = math.floor(100 / FOLDERS_PER_ROW) - 1 local i = 1 while i <= #fieldsInFolder do local field = fieldsInFolder[i] @@ -1284,8 +1288,9 @@ function UI.build() for j = 1, #folderBatch, FOLDERS_PER_ROW do local rowContainer = fieldContainer:box({ w = lvgl.PERCENT_SIZE + 100, + borderPad = 0, flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, + flexPad = lvgl.PAD_SMALL, align = CENTER, color = COLOR_THEME_PRIMARY2 }) From 221aabaa40594e75c0b015df7ef99d76dd01e76f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:38:17 +0200 Subject: [PATCH 018/218] borderPad layout improvements --- color/WIDGETS/ELRSTelemetry/loadable.lua | 4 ++-- color/WIDGETS/ELRSVTXAdmin/loadable.lua | 12 ++++++++---- color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 2 +- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 6 +++--- color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 2 +- color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 2 +- color/WIDGETS/ELRSVTXAdmin/ui/small.lua | 17 ++--------------- 7 files changed, 18 insertions(+), 27 deletions(-) diff --git a/color/WIDGETS/ELRSTelemetry/loadable.lua b/color/WIDGETS/ELRSTelemetry/loadable.lua index befa03d..5f94d86 100644 --- a/color/WIDGETS/ELRSTelemetry/loadable.lua +++ b/color/WIDGETS/ELRSTelemetry/loadable.lua @@ -196,7 +196,7 @@ function WidgetLayout.column(w, h, opa, children) { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, color = COLOR_THEME_PRIMARY2, opacity = opa }, { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, borderPad = lvgl.PAD_SMALL, flexPad = 0, align = LEFT, children = children }, }) end @@ -206,7 +206,7 @@ function WidgetLayout.row(w, h, opa, children) { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, color = COLOR_THEME_PRIMARY2, opacity = opa }, { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, borderPad = lvgl.PAD_SMALL, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = children }, }) end diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/color/WIDGETS/ELRSVTXAdmin/loadable.lua index a5efa0e..1aa437e 100644 --- a/color/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/color/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -624,7 +624,7 @@ function WidgetLayout.column(w, h, opa, children) { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, color = COLOR_THEME_PRIMARY2, opacity = opa }, { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, borderPad = lvgl.PAD_SMALL, flexPad = 0, align = LEFT, children = children }, }) end @@ -634,7 +634,7 @@ function WidgetLayout.row(w, h, opa, children) { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, color = COLOR_THEME_PRIMARY2, opacity = opa }, { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, borderPad = lvgl.PAD_SMALL, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = children }, }) end @@ -729,8 +729,12 @@ function VTXDisplay.buildCheatsheet() local labels = VTXDisplay.build6posLabels() if #labels == 0 then return nil end return { - type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT, visible = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end, + type = "box", + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + align = LEFT, + visible = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end, children = labels, } end diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua index cf3489c..d346841 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -105,7 +105,7 @@ function WidgetUI.buildThird(w, h, opa) { type = "label", align = LEFT, text = VTXDisplay.statusText, color = VTXDisplay.mainColor, font = BOLD, visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 80f3d9b..c9ac942 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -71,8 +71,8 @@ local function buildCheatsheetNarrow() for i = 4, 6 do row2[#row2 + 1] = labels[i] end local vis = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end return - { type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row1 }, - { type = "box", flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row2 } + { type = "box", flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row1 }, + { type = "box", flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row2 } end -- ============================================================================ @@ -162,7 +162,7 @@ function WidgetUI.buildThird(w, h, opa) } -- Active state: fixed-width band column + detail rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 8e8b513..f5841ae 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -101,7 +101,7 @@ function WidgetUI.buildThird(w, h, opa) { type = "label", align = LEFT, text = VTXDisplay.statusText, color = VTXDisplay.mainColor, font = BOLD, visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 917c64a..4b4eee0 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -132,7 +132,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showStatus, } rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua index 04ae91f..833d7cd 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -56,19 +56,6 @@ local function pitModeTextLong() return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" end --- ============================================================================ --- Minimized display helpers (small-screen-specific overrides) --- ============================================================================ - ---- Shorter detail line for compact 320x240 screen. -local function detailLine() - if not Protocol.isActive() then return "" end - if VTX.state.band == 0 then return "" end - local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" - local pit = VTX.state.pitmode and " Pit" or "" - return table.concat({pwr, pit}) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -152,7 +139,7 @@ function WidgetUI.buildThird(w, h, opa) } -- Active state: band + power + pit mode row (no title — too tight on 320x240) rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, @@ -183,7 +170,7 @@ function WidgetUI.buildHalf(w, h, opa) { type = "label", align = LEFT, text = VTXDisplay.bandChannel, color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, visible = VTXDisplay.showChannel }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.powerShort, From 213bfe73db99aff78db1574affc6b370b5f9aa26 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:40:11 +0200 Subject: [PATCH 019/218] fix el18 portrait layout --- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index c9ac942..97069f0 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -115,7 +115,7 @@ function WidgetUI.buildQuarter(w, h, opa) { type = "label", align = LEFT, text = VTXDisplay.statusText, color = VTXDisplay.mainColor, font = BOLD, visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, From 757c451c0fa8d7240f18c568ea58f00649852e98 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:42:50 +0200 Subject: [PATCH 020/218] el18 improvements --- color/WIDGETS/ELRSTelemetry/ui/portrait.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua index af0a78b..01b9b08 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -86,7 +86,7 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.35) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", w = c1w, font = BOLD, align = LEFT, color = heroColorMismatch, text = heroTextLq }, From 930414ca5b918a020f3b510b75d46d239468a617 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 20:49:53 +0200 Subject: [PATCH 021/218] improve tx16s --- color/WIDGETS/ELRSTelemetry/ui/sd.lua | 4 ++-- color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd.lua b/color/WIDGETS/ELRSTelemetry/ui/sd.lua index ed86eb9..1612d9d 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -90,14 +90,14 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { { type = "label", w = c1w, font = BOLD, align = LEFT, color = heroColorMismatch, text = heroTextLq }, { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, text = Telemetry.signalText }, }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, children = { { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText }, }}, diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua index f5841ae..5e7ebcd 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -74,7 +74,7 @@ function WidgetUI.buildQuarter(w, h, opa) { type = "label", align = LEFT, text = VTXDisplay.statusText, color = VTXDisplay.mainColor, font = BOLD, visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, visible = VTXDisplay.showChannel, children = { { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, From 870769c4fb8600d33f826080aa7b8ad6efc44634 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 21:31:20 +0200 Subject: [PATCH 022/218] improve code style --- color/SCRIPTS/TOOLS/expresslrs.lua | 287 +++++++++++++++----- color/WIDGETS/ELRSTelemetry/loadable.lua | 78 +++++- color/WIDGETS/ELRSTelemetry/ui/hd.lua | 167 +++++++++--- color/WIDGETS/ELRSTelemetry/ui/portrait.lua | 149 +++++++--- color/WIDGETS/ELRSTelemetry/ui/sd.lua | 162 ++++++++--- color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 167 +++++++++--- color/WIDGETS/ELRSTelemetry/ui/small.lua | 166 ++++++++--- color/WIDGETS/ELRSTelemetry/ui/topbar.lua | 31 ++- color/WIDGETS/ELRSVTXAdmin/loadable.lua | 68 ++++- color/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 188 ++++++++++--- color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 223 +++++++++++---- color/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 193 +++++++++---- color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 190 +++++++++---- color/WIDGETS/ELRSVTXAdmin/ui/small.lua | 240 +++++++++++----- color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 24 +- 15 files changed, 1785 insertions(+), 548 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index 0c92b15..893361c 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -1114,38 +1114,88 @@ function UI.createNumberRow(pg, field) 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, thickness=0, children={ - {type="label", text=field.name or "", color=COLOR_THEME_PRIMARY1}, - }}, - {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, thickness=0, flexFlow=lvgl.FLOW_ROW, align=LEFT, children={ - {type="numberEdit", min=field.min or 0, max=field.max or 255, - get=function() return field.value or 0 end, - set=function(val) - field.value = val - end, - edited=function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadParentFolder(field) - end, - display=displayFn, - active=function() return not field.disabled end}, - }}, - }}, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + thickness = 0, + children = { + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + children = { + { + type = "label", + color = COLOR_THEME_PRIMARY1, + text = field.name or "", + }, + }, + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + CTRL_PCT, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + thickness = 0, + children = { + { + type = "numberEdit", + min = field.min or 0, + max = field.max or 255, + get = function() return field.value or 0 end, + set = function(val) + field.value = val + end, + edited = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadParentFolder(field) + end, + display = displayFn, + active = function() return not field.disabled end, + }, + }, + }, + }, + }, }) end function UI.createInfoRow(pg, field) 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, thickness=0, children={ - {type="label", text=field.name or "", color=COLOR_THEME_PRIMARY1}, - }}, - {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, thickness=0, flexFlow=lvgl.FLOW_ROW, align=LEFT, children={ - {type="label", text=field.value or ""}, - }}, - }}, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + thickness = 0, + children = { + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + children = { + { + type = "label", + color = COLOR_THEME_PRIMARY1, + text = field.name or "", + }, + }, + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + CTRL_PCT, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + thickness = 0, + children = { + { type = "label", text = field.value or "" }, + }, + }, + }, + }, }) end @@ -1372,22 +1422,44 @@ function ModelMismatchDialog.show(onContinue, onExit) }) dg:build({ - {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ - {type="label", text="Receiver connected but Model ID doesn't match."}, - {type="label", text="This prevents controlling the wrong model."}, - {type="label", text="To use this receiver:"}, - {type="label", text="Set Model Match to OFF"}, - }}, - {type="box", flexFlow=lvgl.FLOW_ROW, flexPad=lvgl.PAD_SMALL, w=lvgl.PERCENT_SIZE+100, children={ - {type="button", text="Continue", w=lvgl.PERCENT_SIZE+48, press=function() - dg:close() - onContinue() - end}, - {type="button", text="Exit to Change Model", w=lvgl.PERCENT_SIZE+48, press=function() - dg:close() - onExit() - end}, - }}, + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "Receiver connected but Model ID doesn't match." }, + { type = "label", text = "This prevents controlling the wrong model." }, + { type = "label", text = "To use this receiver:" }, + { type = "label", text = "Set Model Match to OFF" }, + }, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 48, + text = "Continue", + press = function() + dg:close() + onContinue() + end, + }, + { + type = "button", + w = lvgl.PERCENT_SIZE + 48, + text = "Exit to Change Model", + press = function() + dg:close() + onExit() + end, + }, + }, + }, }) return dg @@ -1410,20 +1482,37 @@ function NoModuleDialog.show(onExit) }) dg:build({ - {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ - {type="label", text="- Internal/External module enabled"}, - {type="label", text="- Protocol set to CRSF"}, - {type="label", text="- Minimum Baud rate (depends on packet rate):"}, - {type="label", text=" 400k for 250Hz", font=SMLSIZE}, - {type="label", text=" 921k for 500Hz", font=SMLSIZE}, - {type="label", text=" 1.87M for F1000", font=SMLSIZE}, - }}, - {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() - onExit() - end}, - }}, + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "- Internal/External module enabled" }, + { type = "label", text = "- Protocol set to CRSF" }, + { type = "label", text = "- Minimum Baud rate (depends on packet rate):" }, + { type = "label", font = SMLSIZE, text = " 400k for 250Hz" }, + { type = "label", font = SMLSIZE, text = " 921k for 500Hz" }, + { type = "label", font = SMLSIZE, text = " 1.87M for F1000" }, + }, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 98, + text = "Exit", + press = function() + dg:close() + onExit() + end, + }, + }, + }, }) return dg @@ -1479,14 +1568,53 @@ function CommandPage.showConfirm(name, info, onConfirm, onCancel) }) container:build({ - {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, - {type="label", text=name or "Command", w=lvgl.PERCENT_SIZE+100, align=CENTER, font=BOLD}, - {type="label", text=info or "", w=lvgl.PERCENT_SIZE+100, align=CENTER, color=COLOR_THEME_DISABLED}, - {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, - {type="box", w=lvgl.PERCENT_SIZE+100, flexFlow=lvgl.FLOW_ROW, flexPad=lvgl.PAD_SMALL, align=CENTER, children={ - {type="button", text="Confirm", w=lvgl.PERCENT_SIZE+49, press=onConfirm}, - {type="button", text="Cancel", w=lvgl.PERCENT_SIZE+49, press=onCancel}, - }}, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + font = BOLD, + text = name or "Command", + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = info or "", + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 49, + text = "Confirm", + press = onConfirm, + }, + { + type = "button", + w = lvgl.PERCENT_SIZE + 49, + text = "Cancel", + press = onCancel, + }, + }, + }, }) return pg @@ -1510,10 +1638,31 @@ function CommandPage.showExecuting(title, onCancel) createSpinner(container) container:build({ - {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_SMALL, thickness=0}, - {type="label", text="Hold [RTN] to exit and keep running", w=lvgl.PERCENT_SIZE+100, align=CENTER, color=COLOR_THEME_DISABLED}, - {type="rectangle", w=lvgl.PERCENT_SIZE+100, h=lvgl.PAD_LARGE, thickness=0}, - {type="button", text="Cancel command", w=lvgl.PERCENT_SIZE+100, press=onCancel}, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = "Hold [RTN] to exit and keep running", + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "button", + w = lvgl.PERCENT_SIZE + 100, + text = "Cancel command", + press = onCancel, + }, }) return pg diff --git a/color/WIDGETS/ELRSTelemetry/loadable.lua b/color/WIDGETS/ELRSTelemetry/loadable.lua index 5f94d86..b69fd9a 100644 --- a/color/WIDGETS/ELRSTelemetry/loadable.lua +++ b/color/WIDGETS/ELRSTelemetry/loadable.lua @@ -193,21 +193,55 @@ local WidgetLayout = {} function WidgetLayout.column(w, h, opa, children) lvgl.build({ - { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, - color = COLOR_THEME_PRIMARY2, opacity = opa }, - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_COLUMN, borderPad = lvgl.PAD_SMALL, flexPad = 0, align = LEFT, - children = children }, + { + type = "rectangle", + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, }) end function WidgetLayout.row(w, h, opa, children) lvgl.build({ - { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, - color = COLOR_THEME_PRIMARY2, opacity = opa }, - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_ROW, borderPad = lvgl.PAD_SMALL, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, - children = children }, + { + type = "rectangle", + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, }) end @@ -270,8 +304,18 @@ end local function createSectionHeader(container, title) container:build({ - { type = "rectangle", w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0 }, - { type = "label", text = title, font = BOLD, color = COLOR_THEME_PRIMARY1 }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = "label", + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, }) end @@ -324,9 +368,13 @@ local function buildFullScreen() -- Model mismatch warning banner fields:build({ - { type = "label", text = "Model Mismatch — RC commands not sent", - font = BOLD, color = RED, - visible = function() return crsf.modelMismatch end }, + { + type = "label", + font = BOLD, + color = RED, + text = "Model Mismatch — RC commands not sent", + visible = function() return crsf.modelMismatch end, + }, }) -- Link Status section diff --git a/color/WIDGETS/ELRSTelemetry/ui/hd.lua b/color/WIDGETS/ELRSTelemetry/ui/hd.lua index 31d9d13..c4eb2db 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -68,19 +68,48 @@ function WidgetUI.buildTiny(w, h, opa) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w local columns = { - { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, - text = heroTextLq }, - }}, - { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = "box", + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.row(w, h, opa, columns) end @@ -90,17 +119,46 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { - { type = "label", w = c1w, font = BOLD, align = LEFT, - color = heroColorMismatch, - text = heroTextLq }, - { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.column(w, h, opa, rows) end @@ -111,26 +169,35 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", align = LEFT, - color = heroColorMismatch, + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return BOLD end return MIDSIZE end, + color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, } @@ -141,23 +208,44 @@ end --- Normal: full telemetry display with title and large fonts. function WidgetUI.buildNormal(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, - { type = "label", align = LEFT, - color = heroColorMismatch, + { + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return 0 end return MIDSIZE end, - text = heroTextLq }, - { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.normal.detail, color = detailColor, - text = Telemetry.signalText }, - { type = "label", font = SMLSIZE, align = LEFT, + text = Telemetry.signalText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = Telemetry.rfDetailText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY3, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then @@ -169,7 +257,8 @@ function WidgetUI.buildNormal(w, h, opa) return string.format("Bat %dS %.2fV", cells, vbat / cells) end return string.format("Bat %.2fV", vbat) - end }, + end, + }, } WidgetLayout.column(w, h, opa, rows) end diff --git a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua index 01b9b08..ae88c61 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -68,15 +68,34 @@ function WidgetUI.buildTiny(w, h, opa) local c1w = math.floor(w * 0.35) local c2w = w - c1w local columns = { - { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, - text = heroTextLq }, - }}, - { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, - text = Telemetry.signalText }, - }}, + { + type = "box", + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = "box", + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, } WidgetLayout.row(w, h, opa, columns) end @@ -86,17 +105,47 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.35) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { - { type = "label", w = c1w, font = BOLD, align = LEFT, - color = heroColorMismatch, - text = heroTextLq }, - { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = "label", + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.column(w, h, opa, rows) end @@ -106,20 +155,30 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, } @@ -130,23 +189,44 @@ end --- Normal: full telemetry display with title. function WidgetUI.buildNormal(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, - { type = "label", align = LEFT, - color = heroColorMismatch, + { + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return BOLD end return MIDSIZE end, - text = heroTextLq }, - { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.normal.detail, color = detailColor, - text = Telemetry.signalText }, - { type = "label", font = SMLSIZE, align = LEFT, + text = Telemetry.signalText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = Telemetry.rfDetailText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY3, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then @@ -158,7 +238,8 @@ function WidgetUI.buildNormal(w, h, opa) return string.format("Bat %dS %.2fV", cells, vbat / cells) end return string.format("Bat %.2fV", vbat) - end }, + end, + }, } WidgetLayout.column(w, h, opa, rows) end diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd.lua b/color/WIDGETS/ELRSTelemetry/ui/sd.lua index 1612d9d..abaa759 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -68,19 +68,48 @@ function WidgetUI.buildTiny(w, h, opa) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w local columns = { - { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, - text = heroTextLq }, - }}, - { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = "box", + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.row(w, h, opa, columns) end @@ -90,17 +119,48 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { - { type = "label", w = c1w, font = BOLD, align = LEFT, - color = heroColorMismatch, - text = heroTextLq }, - { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, children = { - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = "label", + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.column(w, h, opa, rows) end @@ -110,17 +170,23 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- No title row on 480x272 — too tight rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, } @@ -131,23 +197,44 @@ end --- Normal: full telemetry display with title. function WidgetUI.buildNormal(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, - { type = "label", align = LEFT, - color = heroColorMismatch, + { + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return BOLD end return MIDSIZE end, - text = heroTextLq }, - { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.normal.detail, color = detailColor, - text = Telemetry.signalText }, - { type = "label", font = SMLSIZE, align = LEFT, + text = Telemetry.signalText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = Telemetry.rfDetailText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY3, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then @@ -159,7 +246,8 @@ function WidgetUI.buildNormal(w, h, opa) return string.format("Bat %dS %.2fV", cells, vbat / cells) end return string.format("Bat %.2fV", vbat) - end }, + end, + }, } WidgetLayout.column(w, h, opa, rows) end diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 8406bcf..cc8af73 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -69,19 +69,48 @@ function WidgetUI.buildTiny(w, h, opa) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w local columns = { - { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, - text = heroTextLq }, - }}, - { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = "box", + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.row(w, h, opa, columns) end @@ -91,17 +120,46 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { - { type = "label", w = c1w, font = BOLD, align = LEFT, - color = heroColorMismatch, - text = heroTextLq }, - { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.column(w, h, opa, rows) end @@ -112,26 +170,35 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row — 480x320 has more vertical room rows[#rows + 1] = { - type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT, + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", align = LEFT, - color = heroColorMismatch, + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return BOLD end return BOLD end, + color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, } @@ -142,23 +209,44 @@ end --- Normal: full telemetry display with title. function WidgetUI.buildNormal(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, - { type = "label", align = LEFT, - color = heroColorMismatch, + { + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = "label", + align = LEFT, font = function() if Telemetry.statusText() then return BOLD end return MIDSIZE end, - text = heroTextLq }, - { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.normal.detail, color = detailColor, - text = Telemetry.signalText }, - { type = "label", font = SMLSIZE, align = LEFT, + text = Telemetry.signalText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = Telemetry.rfDetailText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY3, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then @@ -170,7 +258,8 @@ function WidgetUI.buildNormal(w, h, opa) return string.format("Bat %dS %.2fV", cells, vbat / cells) end return string.format("Bat %.2fV", vbat) - end }, + end, + }, } WidgetLayout.column(w, h, opa, rows) end diff --git a/color/WIDGETS/ELRSTelemetry/ui/small.lua b/color/WIDGETS/ELRSTelemetry/ui/small.lua index 7009153..5616c88 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/small.lua @@ -69,22 +69,51 @@ function WidgetUI.buildTiny(w, h, opa) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w local columns = { - { type = "box", w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, - text = heroTextLq }, - }}, - { type = "box", w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { - { type = "label", y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = function() - local tlm = Telemetry.readLink() - return Telemetry.getRfModeStr(tlm.rfmd) - end }, - }}, + { + type = "box", + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = "box", + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = "label", + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end, + }, + }, + }, } WidgetLayout.row(w, h, opa, columns) end @@ -94,17 +123,46 @@ end function WidgetUI.buildSmall(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, children = { - { type = "label", w = c1w, font = BOLD, align = LEFT, - color = heroColorMismatch, - text = heroTextLq }, - { type = "label", font = SMLSIZE, align = LEFT, color = detailColor, - text = Telemetry.signalText }, - }}, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = LEFT, children = { - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - }}, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = "box", + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, } WidgetLayout.column(w, h, opa, rows) end @@ -114,17 +172,23 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- No title row — too tight on 320x240 rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.hero, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", font = WidgetUI.fonts.third.detail, align = LEFT, + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, } @@ -135,20 +199,41 @@ end --- Normal: full telemetry display with title. function WidgetUI.buildNormal(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "ExpressLRS", color = COLOR_THEME_SECONDARY1, align = LEFT }, - { type = "label", align = LEFT, - color = heroColorMismatch, + { + type = "label", + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = "label", + align = LEFT, font = function() return BOLD end, - text = heroTextLq }, - { type = "label", font = WidgetUI.fonts.normal.detail, align = LEFT, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.normal.detail, color = detailColor, - text = Telemetry.signalText }, - { type = "label", font = SMLSIZE, align = LEFT, + text = Telemetry.signalText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText }, - { type = "label", font = SMLSIZE, align = LEFT, color = COLOR_THEME_PRIMARY3, + text = Telemetry.rfDetailText, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY3, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then @@ -160,7 +245,8 @@ function WidgetUI.buildNormal(w, h, opa) return string.format("Bat %dS %.2fV", cells, vbat / cells) end return string.format("Bat %.2fV", vbat) - end }, + end, + }, } WidgetLayout.column(w, h, opa, rows) end diff --git a/color/WIDGETS/ELRSTelemetry/ui/topbar.lua b/color/WIDGETS/ELRSTelemetry/ui/topbar.lua index 72852a5..250247a 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -19,9 +19,20 @@ end --- Top bar: two lines stacked, no background. function TopBarUI.build(w, h) lvgl.build({ - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, align = CENTER, children = { - { type = "label", font = SMLSIZE, align = CENTER, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = CENTER, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + children = { + { + type = "label", + align = CENTER, + font = SMLSIZE, color = function() if crsf.modelMismatch then return RED @@ -37,8 +48,12 @@ function TopBarUI.build(w, h) end local tlm = Telemetry.readLink() return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) - end }, - { type = "label", font = SMLSIZE, align = CENTER, + end, + }, + { + type = "label", + align = CENTER, + font = SMLSIZE, color = function() if crsf.modelMismatch then return RED @@ -58,8 +73,10 @@ function TopBarUI.build(w, h) return "" end return table.concat({tostring(rssi), "dBm"}) - end }, - }}, + end, + }, + }, + }, }) end diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/color/WIDGETS/ELRSVTXAdmin/loadable.lua index 1aa437e..dc1775b 100644 --- a/color/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/color/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -621,21 +621,55 @@ local WidgetLayout = {} function WidgetLayout.column(w, h, opa, children) lvgl.build({ - { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, - color = COLOR_THEME_PRIMARY2, opacity = opa }, - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_COLUMN, borderPad = lvgl.PAD_SMALL, flexPad = 0, align = LEFT, - children = children }, + { + type = "rectangle", + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, }) end function WidgetLayout.row(w, h, opa, children) lvgl.build({ - { type = "rectangle", x = 0, y = 0, w = w, h = h, filled = true, - color = COLOR_THEME_PRIMARY2, opacity = opa }, - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_ROW, borderPad = lvgl.PAD_SMALL, flexPad = lvgl.PAD_TINY, align = LEFT + VCENTER, - children = children }, + { + type = "rectangle", + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, }) end @@ -870,8 +904,18 @@ end local function createSectionHeader(container, title) container:build({ - { type = "rectangle", w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0 }, - { type = "label", text = title, font = BOLD, color = COLOR_THEME_PRIMARY1 }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = "label", + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, }) end diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua index d346841..aca6e05 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -50,13 +50,27 @@ function WidgetUI.buildSixth(w, h, opa) local wide = w > 400 local c1w = math.floor(w * 0.22) local columns = { - { type = "label", color = VTXDisplay.mainColor, font = BOLD, - text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, - { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + { + type = "label", + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, } if wide then local labels = VTXDisplay.build6posLabels() @@ -74,19 +88,40 @@ end function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, - { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - }}, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -101,19 +136,44 @@ end function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, - { type = "label", font = WidgetUI.fonts.third.status, align = LEFT, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.third.status, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, + }, }, }, } @@ -128,16 +188,35 @@ end --- 1/2: title + MIDSIZE band + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.half.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -150,19 +229,38 @@ end --- 1/1: title + DBLSIZE band + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then + if cheatsheet then rows[#rows + 1] = cheatsheet end diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 97069f0..5557705 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -71,8 +71,24 @@ local function buildCheatsheetNarrow() for i = 4, 6 do row2[#row2 + 1] = labels[i] end local vis = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end return - { type = "box", flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row1 }, - { type = "box", flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, visible = vis, children = row2 } + { + type = "box", + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = vis, + children = row1, + }, + { + type = "box", + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = vis, + children = row2, + } end -- ============================================================================ @@ -89,13 +105,27 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local columns = { - { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel }, - { type = "label", color = VTXDisplay.mainColor, font = BOLD, - text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, - { type = "label", font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, text = detailLine }, + { + type = "label", + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = detailLine, + }, } local labels = VTXDisplay.build6posLabels() for _, lbl in ipairs(labels) do @@ -111,22 +141,53 @@ end function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.28) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, - { type = "label", font = SMLSIZE, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - { type = "label", font = SMLSIZE, align = LEFT, - color = pitModeColor, - text = pitModeText }, - }}, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeText, + }, + }, + }, } if w < 200 then local r1, r2 = buildCheatsheetNarrow() @@ -152,23 +213,45 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", } -- Loading state: full-width status label rows[#rows + 1] = { - type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } -- Active state: fixed-width band column + detail rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, - { type = "label", font = SMLSIZE, align = LEFT, text = detailLine, - color = COLOR_THEME_SECONDARY1 }, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = detailLine, + }, }, } -- Cheatsheet rows @@ -191,14 +274,33 @@ end --- 1/2: title + band/channel + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = function() if not Protocol.isActive() then return "" @@ -210,7 +312,7 @@ function WidgetUI.buildHalf(w, h, opa) local pit = VTX.state.pitmode and " Pit" or "" return table.concat({pwr, pit}) end, - color = COLOR_THEME_SECONDARY1 }, + }, } if w < 200 then local r1, r2 = buildCheatsheetNarrow() @@ -231,16 +333,35 @@ end --- 1/1: title + MIDSIZE band/channel + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } if w < 200 then local r1, r2 = buildCheatsheetNarrow() diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 5e7ebcd..efd4e92 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -47,13 +47,27 @@ function WidgetUI.buildSixth(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local columns = { - { type = "label", color = VTXDisplay.mainColor, font = BOLD, - text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, - { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + { + type = "label", + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, } if wide then local labels = VTXDisplay.build6posLabels() @@ -71,18 +85,40 @@ end function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, - { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - }}, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -97,17 +133,44 @@ end function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, - { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, - color = COLOR_THEME_SECONDARY1 }, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, }, }, } @@ -122,16 +185,35 @@ end --- 1/2: title + band/status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -144,16 +226,35 @@ end --- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 4b4eee0..31cfcf6 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -62,13 +62,27 @@ function WidgetUI.buildSixth(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local columns = { - { type = "label", color = VTXDisplay.mainColor, font = BOLD, - text = VTXDisplay.statusText, visible = VTXDisplay.showStatus }, - { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine }, + { + type = "label", + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, } if wide then local labels = VTXDisplay.build6posLabels() @@ -87,26 +101,50 @@ function WidgetUI.buildQuarter(w, h, opa) local wide = w > 200 local c1w = math.floor(w * 0.22) local row1 = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, - { type = "label", font = WidgetUI.fonts.quarter.status, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, } if wide then row1[#row1 + 1] = { - type = "label", font = SMLSIZE, align = LEFT, + type = "label", + align = LEFT, + font = SMLSIZE, color = pitModeColor, text = pitModeText, } end local rows = { - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, - children = row1 }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + visible = VTXDisplay.showChannel, + children = row1, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -124,21 +162,43 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row — 480x320 has more vertical room than 480x272 rows[#rows + 1] = { - type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1, + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", } rows[#rows + 1] = { - type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, - { type = "label", font = SMLSIZE, align = LEFT, text = VTXDisplay.detailLine, - color = COLOR_THEME_SECONDARY1 }, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, }, } local cheatsheet = VTXDisplay.buildCheatsheet() @@ -152,16 +212,35 @@ end --- 1/2: title + band/status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -174,16 +253,35 @@ end --- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua index 833d7cd..98582b4 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -70,18 +70,35 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { - { type = "label", color = VTXDisplay.mainColor, font = BOLD, + { + type = "label", + font = BOLD, + color = VTXDisplay.mainColor, text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus }, - { type = "label", w = c1w, color = VTXDisplay.mainColor, - font = WidgetUI.fonts.sixth.status, text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel }, - { type = "label", font = SMLSIZE, align = LEFT, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - { type = "label", font = SMLSIZE, align = LEFT, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = pitModeColor, - text = pitModeText }, + text = pitModeText, + }, } local labels = VTXDisplay.build6posLabels() for _, lbl in ipairs(labels) do @@ -97,26 +114,52 @@ end function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.quarter.status }, - { type = "label", font = SMLSIZE, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - { type = "label", font = SMLSIZE, align = LEFT, - color = RED, - text = function() - if not Protocol.isActive() or VTX.state.band == 0 then - return "" - end - return VTX.state.pitmode and "Pit" or "" - end }, - }}, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = RED, + text = function() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit" or "" + end, + }, + }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -133,23 +176,45 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Loading state: full-width status label rows[#rows + 1] = { - type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } -- Active state: band + power + pit mode row (no title — too tight on 320x240) rows[#rows + 1] = { - type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, visible = VTXDisplay.showChannel, + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, children = { - { type = "label", w = c1w, align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.third.status }, - { type = "label", font = SMLSIZE, align = LEFT, + { + type = "label", + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - { type = "label", font = SMLSIZE, align = LEFT, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, color = pitModeColor, - text = pitModeText }, + text = pitModeText, + }, }, } local cheatsheet = VTXDisplay.buildCheatsheet() @@ -163,22 +228,52 @@ end --- 1/2: title + band + status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.half.hero, - visible = VTXDisplay.showChannel }, - { type = "box", w = w, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, - align = LEFT + VCENTER, children = { - { type = "label", font = SMLSIZE, align = LEFT, - text = VTXDisplay.powerShort, - color = COLOR_THEME_SECONDARY1 }, - { type = "label", font = SMLSIZE, align = LEFT, - color = pitModeColor, - text = pitModeTextLong }, - }}, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "box", + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = "label", + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeTextLong, + }, + }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -191,16 +286,35 @@ end --- 1/1: title + MIDSIZE band + status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) local rows = { - { type = "label", font = BOLD, text = "VTX Admin", color = COLOR_THEME_SECONDARY1 }, - { type = "label", align = LEFT, text = VTXDisplay.statusText, - color = VTXDisplay.mainColor, font = BOLD, - visible = VTXDisplay.showStatus }, - { type = "label", align = LEFT, text = VTXDisplay.bandChannel, - color = VTXDisplay.mainColor, font = WidgetUI.fonts.full.hero, - visible = VTXDisplay.showChannel }, - { type = "label", font = WidgetUI.fonts.full.detail, align = LEFT, + { + type = "label", + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = "label", + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = "label", + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLong, - color = COLOR_THEME_SECONDARY1 }, + }, } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua index c9edb7e..b8b236b 100644 --- a/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua +++ b/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -22,9 +22,21 @@ end --- Top bar: ultra-compact single line, no background. function TopBarUI.build(w, h) lvgl.build({ - { type = "box", x = 0, y = 0, w = w, h = h, - flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, align = CENTER + VCENTER, children = { - { type = "label", font = MIDSIZE, align = CENTER, color = COLOR_THEME_PRIMARY2, + { + type = "box", + x = 0, + y = 0, + w = w, + h = h, + align = CENTER + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = "label", + align = CENTER, + font = MIDSIZE, + color = COLOR_THEME_PRIMARY2, text = function() local s = getStatusLine() if s == "--" then @@ -32,8 +44,10 @@ function TopBarUI.build(w, h) end local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" return table.concat({s, pwr}, " ") - end }, - }}, + end, + }, + }, + }, }) end From eeec1e06e706d8f6ddb0df5802799a56fd9dd195 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 14 Feb 2026 21:43:55 +0200 Subject: [PATCH 023/218] unify size naming convention --- color/WIDGETS/ELRSTelemetry/ui/hd.lua | 38 ++++++++------- color/WIDGETS/ELRSTelemetry/ui/portrait.lua | 53 +++++++++------------ color/WIDGETS/ELRSTelemetry/ui/sd.lua | 36 +++++++------- color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 36 +++++++------- color/WIDGETS/ELRSTelemetry/ui/small.lua | 36 +++++++------- 5 files changed, 96 insertions(+), 103 deletions(-) diff --git a/color/WIDGETS/ELRSTelemetry/ui/hd.lua b/color/WIDGETS/ELRSTelemetry/ui/hd.lua index c4eb2db..7e9bf5c 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -14,16 +14,16 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 800x480. WidgetUI.breakpoints = { topBarW = 200, - tinyH = 74, - smallH = 104, + sixthH = 74, + quarterH = 104, thirdH = 146, } WidgetUI.fonts = { - tiny = { hero = BOLD }, - small = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - normal = { hero = DBLSIZE, detail = 0 }, + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = DBLSIZE, detail = 0 }, } -- ============================================================================ @@ -61,9 +61,9 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ crsf = crsf, Telemetry = Telemetry, }) ---- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildTiny(w, h, opa) +function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w @@ -114,9 +114,9 @@ function WidgetUI.buildTiny(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. --- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildSmall(w, h, opa) +function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { @@ -124,6 +124,7 @@ function WidgetUI.buildSmall(w, h, opa) w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, + borderPad = 0, flexPad = lvgl.PAD_TINY, children = { { @@ -148,6 +149,7 @@ function WidgetUI.buildSmall(w, h, opa) w = w, align = LEFT, flexFlow = lvgl.FLOW_ROW, + borderPad = 0, flexPad = lvgl.PAD_TINY, children = { { @@ -205,8 +207,8 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Normal: full telemetry display with title and large fonts. -function WidgetUI.buildNormal(w, h, opa) +--- 1/1: full telemetry display with title and large fonts. +function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", @@ -230,7 +232,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, - font = WidgetUI.fonts.normal.detail, + font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, @@ -269,11 +271,11 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) - elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildNormal(w, h, opa) + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildFull(w, h, opa) end end diff --git a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua index ae88c61..6c7be6f 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -14,16 +14,16 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x480 portrait. WidgetUI.breakpoints = { topBarW = 80, - tinyH = 55, - smallH = 78, + sixthH = 55, + quarterH = 78, thirdH = 110, } WidgetUI.fonts = { - tiny = { hero = BOLD }, - small = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - normal = { hero = MIDSIZE, detail = SMLSIZE }, + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -61,10 +61,10 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ crsf = crsf, Telemetry = Telemetry, }) ---- Tiny: single line — LQ (bold) + Range/dBm (colored). +--- 1/6: single line — LQ (bold) + Range/dBm (colored). --- Portrait is narrower so skip RF mode/power. --- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildTiny(w, h, opa) +function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.35) local c2w = w - c1w local columns = { @@ -100,9 +100,9 @@ function WidgetUI.buildTiny(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. --- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildSmall(w, h, opa) +function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.35) local rows = { { @@ -131,20 +131,11 @@ function WidgetUI.buildSmall(w, h, opa) }, }, { - type = "box", - w = w, + type = "label", align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - children = { - { - type = "label", - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - }, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, }, } WidgetLayout.column(w, h, opa, rows) @@ -186,8 +177,8 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Normal: full telemetry display with title. -function WidgetUI.buildNormal(w, h, opa) +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", @@ -211,7 +202,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, - font = WidgetUI.fonts.normal.detail, + font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, @@ -250,11 +241,11 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) - elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildNormal(w, h, opa) + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildFull(w, h, opa) end end diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd.lua b/color/WIDGETS/ELRSTelemetry/ui/sd.lua index abaa759..058070c 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -14,16 +14,16 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x272. WidgetUI.breakpoints = { topBarW = 100, - tinyH = 37, - smallH = 52, + sixthH = 37, + quarterH = 52, thirdH = 73, } WidgetUI.fonts = { - tiny = { hero = BOLD }, - small = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - normal = { hero = MIDSIZE, detail = SMLSIZE }, + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -61,9 +61,9 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ crsf = crsf, Telemetry = Telemetry, }) ---- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildTiny(w, h, opa) +function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w @@ -114,9 +114,9 @@ function WidgetUI.buildTiny(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. --- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildSmall(w, h, opa) +function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { @@ -194,8 +194,8 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Normal: full telemetry display with title. -function WidgetUI.buildNormal(w, h, opa) +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", @@ -219,7 +219,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, - font = WidgetUI.fonts.normal.detail, + font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, @@ -258,11 +258,11 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) - elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildNormal(w, h, opa) + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildFull(w, h, opa) end end diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index cc8af73..e42088a 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -15,16 +15,16 @@ local WidgetUI = {} -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { topBarW = 100, - tinyH = 44, - smallH = 62, + sixthH = 44, + quarterH = 62, thirdH = 86, } WidgetUI.fonts = { - tiny = { hero = BOLD }, - small = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - normal = { hero = MIDSIZE, detail = SMLSIZE }, + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -62,9 +62,9 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ crsf = crsf, Telemetry = Telemetry, }) ---- Tiny: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildTiny(w, h, opa) +function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w @@ -115,9 +115,9 @@ function WidgetUI.buildTiny(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. --- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildSmall(w, h, opa) +function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { @@ -206,8 +206,8 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Normal: full telemetry display with title. -function WidgetUI.buildNormal(w, h, opa) +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", @@ -231,7 +231,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, - font = WidgetUI.fonts.normal.detail, + font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, @@ -270,11 +270,11 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) - elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildNormal(w, h, opa) + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildFull(w, h, opa) end end diff --git a/color/WIDGETS/ELRSTelemetry/ui/small.lua b/color/WIDGETS/ELRSTelemetry/ui/small.lua index 5616c88..5b10ca1 100644 --- a/color/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/color/WIDGETS/ELRSTelemetry/ui/small.lua @@ -15,16 +15,16 @@ local WidgetUI = {} -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { topBarW = 80, - tinyH = 30, - smallH = 42, + sixthH = 30, + quarterH = 42, thirdH = 58, } WidgetUI.fonts = { - tiny = { hero = BOLD }, - small = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - normal = { hero = MIDSIZE, detail = SMLSIZE }, + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -62,9 +62,9 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ crsf = crsf, Telemetry = Telemetry, }) ---- Tiny: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). +--- 1/6: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildTiny(w, h, opa) +function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local c2w = math.floor(w * 0.40) local c3w = w - c1w - c2w @@ -118,9 +118,9 @@ function WidgetUI.buildTiny(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- Small: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. --- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildSmall(w, h, opa) +function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { @@ -196,8 +196,8 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Normal: full telemetry display with title. -function WidgetUI.buildNormal(w, h, opa) +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) local rows = { { type = "label", @@ -218,7 +218,7 @@ function WidgetUI.buildNormal(w, h, opa) { type = "label", align = LEFT, - font = WidgetUI.fonts.normal.detail, + font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, @@ -257,11 +257,11 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.tinyH then WidgetUI.buildTiny(w, h, opa) - elseif h < bp.smallH then WidgetUI.buildSmall(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildNormal(w, h, opa) + if w < bp.topBarW then TopBarUI.build(w, h) + elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + else WidgetUI.buildFull(w, h, opa) end end From c12df12f3867885a476f2f46b4d11c04fdef1c6b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 21:27:01 +0200 Subject: [PATCH 024/218] refactor elrsv1 detected message --- color/SCRIPTS/TOOLS/expresslrs.lua | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index 893361c..d360572 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -248,6 +248,7 @@ Protocol = { -- Status/flags (parsed from ELRS info messages) elrsFlags = 0, elrsFlagsInfo = "", + elrsV1Detected = false, receivedPackets = nil, lostPackets = nil, @@ -290,6 +291,7 @@ function Protocol.reset() -- Status/flags Protocol.elrsFlags = 0 Protocol.elrsFlagsInfo = "" + Protocol.elrsV1Detected = false Protocol.receivedPackets = nil Protocol.lostPackets = nil @@ -850,8 +852,7 @@ function Protocol.parseElrsV1Message(data) if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then return end - Protocol.fieldPopup = { id = 0, status = Protocol.CRSF.CMD_EXECUTING, timeout = 0xFF, info = "ERROR: 1.x firmware" } - Protocol.fieldTimeout = getTime() + 0xFFFF + Protocol.elrsV1Detected = true end -- ============================================================================ @@ -1917,6 +1918,18 @@ local function run(event, touchState) -- CRSF polling local pollResult = Protocol.poll() + -- Check for ELRS 1.x firmware (unsupported) + if Protocol.elrsV1Detected then + if not UI.uiBuilt then + Dialogs.showMessage({ + title = "Unsupported Firmware", + message = "ELRS 1.x firmware detected. Please update to 3.x.", + }) + UI.uiBuilt = true + end + return 0 + end + -- Handle device info update if pollResult.deviceInfo then -- Change device if protocol indicates current device was updated From 2fa0161b9070d38f9297bc5c7aa037b2cdf9d4d0 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 22:00:14 +0200 Subject: [PATCH 025/218] fix device info packets handling --- color/SCRIPTS/TOOLS/expresslrs.lua | 86 +++++++++++++++--------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index d360572..a857574 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -58,24 +58,25 @@ function App.checkCrsfModule() return App.crsfModuleFound end --- Coordinator: changes device and triggers UI update --- When switching to a DIFFERENT device (user action), pushes a device entry --- onto the navigation stack so the user can navigate back. --- When the SAME device is updated (initial setup), just resets navigation. -function App.changeDevice(devId) - local device = Protocol.getDevice(devId) +--- Active device announced/re-announced. Resets navigation (field tree rebuilding). +function App.loadDevice(device) + if Protocol.setDevice(device) then + Navigation.reset() + UI.invalidate() + end +end + +--- User picked a different device from "Other Devices" list. +--- Pushes a navigation entry so Back returns to previous device. +function App.userSwitchDevice(deviceId) + local device = Protocol.getDevice(deviceId) if not device then return end local prevDeviceId = Protocol.deviceId - local isSwitching = (prevDeviceId ~= devId) if Protocol.setDevice(device) then - if isSwitching then - Navigation.openDevice(device.name, prevDeviceId) - else - Navigation.reset() - end - return UI.invalidate() + Navigation.openDevice(device.name, prevDeviceId) + UI.invalidate() end end @@ -347,14 +348,14 @@ function Protocol.setDevice(device) if not device then return false end - if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fldcnt then + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then return false end Protocol.deviceId = device.id Protocol.elrsFlags = 0 Protocol.deviceName = device.name - Protocol.fieldsCount = device.fldcnt + Protocol.fieldsCount = device.fieldCount Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER @@ -755,14 +756,9 @@ function Protocol.parseDeviceInfoMessage(data) Protocol.devices[#Protocol.devices + 1] = device end device.name = newName - device.fldcnt = data[offset + 12] + device.fieldCount = data[offset + 12] device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID - - -- Return signal - caller handles device change and navigation - -- shouldChangeDevice: true if this is info about the currently selected device - -- isNewDevice: true if this device was not previously known - local shouldChangeDevice = (Protocol.deviceId == id) - return { shouldChangeDevice = shouldChangeDevice, deviceId = id, isNewDevice = isNew } + return device, isNew end function Protocol.parseParameterInfoMessage(data) @@ -856,17 +852,24 @@ function Protocol.parseElrsV1Message(data) end -- ============================================================================ --- Protocol: Main CRSF communication loop (renamed from refreshNext) +-- Protocol: Main CRSF communication loop -- ============================================================================ function Protocol.poll() local command, data - local deviceInfoResult = nil + local targetDevice = nil + local anyNewDevice = false repeat command, data = Protocol.pop() if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then - deviceInfoResult = Protocol.parseDeviceInfoMessage(data) + local device, isNew = Protocol.parseDeviceInfoMessage(data) + if device.id == Protocol.deviceId then + targetDevice = device + end + if isNew then + anyNewDevice = true + end elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then Protocol.parseParameterInfoMessage(data) if #Protocol.loadQueue > 0 then @@ -881,6 +884,10 @@ function Protocol.poll() end until command == nil + return targetDevice, anyNewDevice +end + +function Protocol.tick() -- Auto-discover other devices when link transitions to connected local connected = Protocol.isConnected() if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then @@ -923,8 +930,6 @@ function Protocol.poll() Protocol.backgroundLoading = false end end - - return { deviceInfo = deviceInfoResult } end -- ============================================================================ @@ -1298,7 +1303,7 @@ function UI.build() text = device.name or "Unknown", w = lvgl.PERCENT_SIZE + 100, press = function() - App.changeDevice(device.id) + App.userSwitchDevice(device.id) end }) end @@ -1916,7 +1921,8 @@ local function run(event, touchState) end -- CRSF polling - local pollResult = Protocol.poll() + local targetDevice, anyNewDevice = Protocol.poll() + Protocol.tick() -- Check for ELRS 1.x firmware (unsupported) if Protocol.elrsV1Detected then @@ -1930,20 +1936,14 @@ local function run(event, touchState) return 0 end - -- Handle device info update - if pollResult.deviceInfo then - -- Change device if protocol indicates current device was updated - if pollResult.deviceInfo.shouldChangeDevice then - App.changeDevice(pollResult.deviceInfo.deviceId) - end - -- Refresh UI if a new device appeared (shows "Other Devices" button at root, - -- or updates the device list if already viewing that folder). - -- At root level, wait until the folder has finished loading before rebuilding. - if pollResult.deviceInfo.isNewDevice and UI.folderWasReady then - UI.invalidate() - elseif Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES then - UI.invalidate() - end + -- Activate the target device if it announced/re-announced this cycle + if targetDevice then + App.loadDevice(targetDevice) + end + -- Refresh UI if a new device appeared (shows "Other Devices" button at root, + -- or updates the device list if already viewing that folder). + if anyNewDevice and (Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES or UI.folderWasReady) then + UI.invalidate() end -- Handle command popups From 3385bf3c3454fefabad436988491d8781cfe9473 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 22:01:27 +0200 Subject: [PATCH 026/218] fix device info handling --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 95 ++++++++++++++++++----------- 1 file changed, 58 insertions(+), 37 deletions(-) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua index f7e41f8..d4ecc98 100644 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -77,23 +77,29 @@ function App.checkCrsfModule() return App.crsfModuleFound end --- Coordinator: changes device and triggers UI update -function App.changeDevice(devId) - local device = Protocol.getDevice(devId) +--- Active device announced/re-announced. Resets navigation (field tree rebuilding). +function App.loadDevice(device) + if Protocol.setDevice(device) then + Navigation.reset() + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() + end +end + +--- User picked a different device from "Other Devices" list. +--- Pushes a navigation entry so Back returns to previous device. +function App.userSwitchDevice(deviceId) + local device = Protocol.getDevice(deviceId) if not device then return end local prevDeviceId = Protocol.deviceId - local isSwitching = (prevDeviceId ~= devId) if Protocol.setDevice(device) then - if isSwitching then - Navigation.openDevice(device.name, prevDeviceId) - else - Navigation.reset() - end + Navigation.openDevice(device.name, prevDeviceId) UI.lineIndex = 1 UI.pageOffset = 0 - return UI.invalidate() + UI.invalidate() end end @@ -113,7 +119,10 @@ function App.handleBack() if Navigation.isAtRoot() then -- At root: reload everything (like original elrs.lua) if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then - App.changeDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + if txDevice then + App.loadDevice(txDevice) + end else Protocol.allocateFields() Protocol.reloadAllFields() @@ -371,14 +380,14 @@ function Protocol.setDevice(device) if not device then return false end - if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fldcnt then + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then return false end Protocol.deviceId = device.id Protocol.elrsFlags = 0 Protocol.deviceName = device.name - Protocol.fieldsCount = device.fldcnt + Protocol.fieldsCount = device.fieldCount Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER @@ -755,11 +764,9 @@ function Protocol.parseDeviceInfoMessage(data) Protocol.devices[#Protocol.devices + 1] = device end device.name = newName - device.fldcnt = data[offset + 12] + device.fieldCount = data[offset + 12] device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID - - local shouldChangeDevice = (Protocol.deviceId == id) - return { shouldChangeDevice = shouldChangeDevice, deviceId = id, isNewDevice = isNew } + return device, isNew end function Protocol.parseParameterInfoMessage(data) @@ -847,8 +854,7 @@ function Protocol.parseElrsV1Message(data) if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then return end - Protocol.fieldPopup = { id = 0, status = Protocol.CRSF.CMD_EXECUTING, timeout = 0xFF, info = "ERROR: 1.x firmware" } - Protocol.fieldTimeout = getTime() + 0xFFFF + Protocol.elrsV1Detected = true end -- ============================================================================ @@ -857,12 +863,19 @@ end function Protocol.poll() local command, data - local deviceInfoResult = nil + local targetDevice = nil + local anyNewDevice = false repeat command, data = Protocol.pop() if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then - deviceInfoResult = Protocol.parseDeviceInfoMessage(data) + local device, isNew = Protocol.parseDeviceInfoMessage(data) + if device.id == Protocol.deviceId then + targetDevice = device + end + if isNew then + anyNewDevice = true + end elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then Protocol.parseParameterInfoMessage(data) if #Protocol.loadQueue > 0 then @@ -877,6 +890,10 @@ function Protocol.poll() end until command == nil + return targetDevice, anyNewDevice +end + +function Protocol.tick() -- Auto-discover other devices when link transitions to connected local connected = Protocol.isConnected() if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then @@ -919,8 +936,6 @@ function Protocol.poll() Protocol.backgroundLoading = false end end - - return { deviceInfo = deviceInfoResult } end -- ============================================================================ @@ -1138,8 +1153,7 @@ local function fieldFloatDisplay(field, y, attr) end local function fieldTextSelDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, field.values[field.value + 1] or "ERR", attr) - lcd.drawText(lcd.getLastPos(), y, field.unit, 0) + lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. field.unit, attr) end local function fieldStringDisplay(field, y, attr) @@ -1251,7 +1265,7 @@ function UI.handleEvent(event) elseif ft == Protocol.CRSF.DEVICE_FOLDER then App.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") elseif ft == Protocol.CRSF.DEVICE then - App.changeDevice(field.id) + App.userSwitchDevice(field.id) elseif ft == Protocol.CRSF.COMMAND then Protocol.handleCommandSave(field) elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then @@ -1438,18 +1452,25 @@ local function run(event, touchState) end -- CRSF polling - local pollResult = Protocol.poll() + local targetDevice, anyNewDevice = Protocol.poll() + Protocol.tick() + + -- Check for ELRS 1.x firmware (unsupported) + if Protocol.elrsV1Detected then + lcd.clear() + lcd.drawText(2, 0, "Unsupported Firmware", MIDSIZE) + lcd.drawText(2, UI.textSize * 3, "ELRS 1.x firmware detected.") + lcd.drawText(2, UI.textSize * 4, "Please update to 3.x.") + return 0 + end - -- Handle device info update - if pollResult.deviceInfo then - if pollResult.deviceInfo.shouldChangeDevice then - App.changeDevice(pollResult.deviceInfo.deviceId) - end - if pollResult.deviceInfo.isNewDevice then - UI.invalidate() - elseif Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES then - UI.invalidate() - end + -- Activate the target device if it announced/re-announced this cycle + if targetDevice then + App.loadDevice(targetDevice) + end + -- Refresh UI if a new device appeared + if anyNewDevice then + UI.invalidate() end -- Warning flashing timer From 655617b7fd8fd217c22e3870b1bcfc112e575f00 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 22:09:05 +0200 Subject: [PATCH 027/218] add generic alerts method --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 37 +++++++++++++---------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua index d4ecc98..0d32bdb 100644 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -1380,29 +1380,17 @@ function UI.drawPopup(event) end -- ============================================================================ --- No Module screen (from elrs.lua checkCrsfModule error display) +-- Alert screen (clear screen + title + body messages) -- ============================================================================ -local function drawNoModule() +local function drawAlert(title, msgs) lcd.clear() local y = 0 - lcd.drawText(2, y, " No ExpressLRS", MIDSIZE) + lcd.drawText(2, y, title, MIDSIZE) y = y + (UI.textSize * 2) - 2 - local msgs = { - " Enable a CRSF Internal", - " or External module in", - " Model settings", - " If module is internal", - " also set Internal RF to", - " CRSF in SYS->Hardware", - } - for i, msg in ipairs(msgs) do + for _, msg in ipairs(msgs) do lcd.drawText(2, y, msg) y = y + UI.textSize - if i == 3 then - lcd.drawLine(0, y, LCD_W, y, SOLID, INVERS) - y = y + 2 - end end end @@ -1447,7 +1435,14 @@ local function run(event, touchState) -- Check for CRSF module if not App.checkCrsfModule() then - drawNoModule() + drawAlert(" No ExpressLRS", { + " Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + " also set Internal RF to", + " CRSF in SYS->Hardware", + }) return 0 end @@ -1457,10 +1452,10 @@ local function run(event, touchState) -- Check for ELRS 1.x firmware (unsupported) if Protocol.elrsV1Detected then - lcd.clear() - lcd.drawText(2, 0, "Unsupported Firmware", MIDSIZE) - lcd.drawText(2, UI.textSize * 3, "ELRS 1.x firmware detected.") - lcd.drawText(2, UI.textSize * 4, "Please update to 3.x.") + drawAlert("Unsupported Firmware", { + "ELRS 1.x firmware detected.", + "Please update to 3.x.", + }) return 0 end From 305ad2173cd147c7b3e26a14d5c298c3b435a0c7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 22:26:52 +0200 Subject: [PATCH 028/218] remove debounced save --- color/SCRIPTS/TOOLS/expresslrs.lua | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index a857574..efc4e23 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -82,7 +82,6 @@ end -- Coordinator: opens a folder, loads its children, and refreshes UI function App.openFolder(folderId, folderName) - Protocol.flushPendingSaves() Navigation.openFolder(folderId, folderName) Protocol.loadFolderChildren(folderId) return UI.invalidate() @@ -90,7 +89,6 @@ end -- Back button handler: navigate back or show exit confirmation function App.handleBack() - Protocol.flushPendingSaves() if Navigation.isAtRoot() then Dialogs.showConfirm({ title = "Exit", @@ -264,10 +262,6 @@ Protocol = { expectChunksRemain = -1, backgroundLoading = false, - -- Debounce: deferred saves for continuous controls (numberEdit) - DEBOUNCE_SAVE_DELAY = 30, -- 300ms in getTime() ticks (10ms each) - pendingSaves = {}, -- keyed by field.id: { field, timeout } - -- Connection transition tracking (for auto-discovery on reconnect) wasConnected = false, } @@ -306,7 +300,6 @@ function Protocol.reset() Protocol.loadQueue = {} Protocol.expectChunksRemain = -1 Protocol.backgroundLoading = false - Protocol.pendingSaves = {} Protocol.wasConnected = false end @@ -657,18 +650,6 @@ function Protocol.reloadParentFolder(field) end end -function Protocol.debounceSave(field) - Protocol.pendingSaves[field.id] = { field = field, timeout = getTime() + Protocol.DEBOUNCE_SAVE_DELAY } -end - -function Protocol.flushPendingSaves() - for id, ps in pairs(Protocol.pendingSaves) do - Protocol.pendingSaves[id] = nil - Protocol.fieldIntSave(ps.field) - Protocol.reloadParentFolder(ps.field) - end -end - function Protocol.reloadRelatedFields(field) Protocol.reloadParentFolder(field) @@ -897,15 +878,6 @@ function Protocol.tick() Protocol.wasConnected = connected local time = getTime() - -- Flush any debounced saves whose timer has expired - for id, ps in pairs(Protocol.pendingSaves) do - if time > ps.timeout then - Protocol.pendingSaves[id] = nil - Protocol.fieldIntSave(ps.field) - Protocol.reloadParentFolder(ps.field) - end - end - if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) From 4a55bd2b35a78c4c758a5918f05d98de75cd7f8b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 22:27:27 +0200 Subject: [PATCH 029/218] remove debounced save --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua index 0d32bdb..1d0e286 100644 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -105,7 +105,6 @@ end -- Coordinator: opens a folder, loads its children, and refreshes UI function App.openFolder(folderId, folderName) - Protocol.flushPendingSaves() Navigation.openFolder(folderId, folderName) Protocol.loadFolderChildren(folderId) UI.lineIndex = 1 @@ -115,7 +114,6 @@ end -- Back button handler: navigate back or reload at root function App.handleBack() - Protocol.flushPendingSaves() if Navigation.isAtRoot() then -- At root: reload everything (like original elrs.lua) if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then @@ -310,10 +308,6 @@ Protocol = { expectChunksRemain = -1, backgroundLoading = false, - -- Debounce: deferred saves for continuous controls - DEBOUNCE_SAVE_DELAY = 30, - pendingSaves = {}, - -- Connection transition tracking wasConnected = false, } @@ -345,7 +339,6 @@ function Protocol.reset() Protocol.loadQueue = {} Protocol.expectChunksRemain = -1 Protocol.backgroundLoading = false - Protocol.pendingSaves = {} Protocol.wasConnected = false end @@ -666,18 +659,6 @@ function Protocol.reloadParentFolder(field) end end -function Protocol.debounceSave(field) - Protocol.pendingSaves[field.id] = { field = field, timeout = getTime() + Protocol.DEBOUNCE_SAVE_DELAY } -end - -function Protocol.flushPendingSaves() - for id, ps in pairs(Protocol.pendingSaves) do - Protocol.pendingSaves[id] = nil - Protocol.fieldIntSave(ps.field) - Protocol.reloadParentFolder(ps.field) - end -end - function Protocol.reloadRelatedFields(field) Protocol.reloadParentFolder(field) @@ -903,15 +884,6 @@ function Protocol.tick() Protocol.wasConnected = connected local time = getTime() - -- Flush any debounced saves whose timer has expired - for id, ps in pairs(Protocol.pendingSaves) do - if time > ps.timeout then - Protocol.pendingSaves[id] = nil - Protocol.fieldIntSave(ps.field) - Protocol.reloadParentFolder(ps.field) - end - end - if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) From b2c83e422fc90592d03ed66109cb71c5263d5543 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 23:02:45 +0200 Subject: [PATCH 030/218] Send initial DEVICE_PING after 1 second, then every 5 second while tool script is open --- color/SCRIPTS/TOOLS/expresslrs.lua | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index efc4e23..d074365 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -242,7 +242,6 @@ Protocol = { -- Devices collection devices = {}, - devicesRefreshTimeout = 50, -- Status/flags (parsed from ELRS info messages) elrsFlags = 0, @@ -253,6 +252,7 @@ Protocol = { -- Protocol timing linkstatTimeout = 100, + pingTimeout = 0, -- Communication state fieldTimeout = 0, @@ -281,7 +281,6 @@ function Protocol.reset() -- Devices collection Protocol.devices = {} - Protocol.devicesRefreshTimeout = 50 -- Status/flags Protocol.elrsFlags = 0 @@ -292,6 +291,7 @@ function Protocol.reset() -- Protocol timing Protocol.linkstatTimeout = 100 + Protocol.pingTimeout = 0 -- Communication state Protocol.fieldTimeout = 0 @@ -872,20 +872,28 @@ function Protocol.tick() -- Auto-discover other devices when link transitions to connected local connected = Protocol.isConnected() if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) - Protocol.devicesRefreshTimeout = getTime() + 100 + Protocol.pingTimeout = 0 end Protocol.wasConnected = connected local time = getTime() + -- Ping the radio transmitter to discover other devices + -- We do this every 3 seconds to ensure we always have the latest device list + -- On initial connection, we ping immediately to discover other devices + if time > Protocol.pingTimeout then + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + if #Protocol.devices == 0 then + Protocol.pingTimeout = time + 100 -- 1s fast discovery + else + Protocol.pingTimeout = time + 500 -- 5s + end + end + if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end - elseif time > Protocol.devicesRefreshTimeout and #Protocol.devices == 0 then - Protocol.devicesRefreshTimeout = time + 100 - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) elseif time > Protocol.linkstatTimeout then if Protocol.deviceIsELRS_TX then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) From c959997028068c10938581d4603f7e8df592916f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 15 Feb 2026 23:03:11 +0200 Subject: [PATCH 031/218] Send initial DEVICE_PING after 1 second, then every 5 second while tool script is open --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua index 1d0e286..4854ea2 100644 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -289,7 +289,6 @@ Protocol = { -- Devices collection devices = {}, - devicesRefreshTimeout = 50, -- Status/flags elrsFlags = 0, @@ -299,6 +298,7 @@ Protocol = { -- Protocol timing linkstatTimeout = 100, + pingTimeout = 0, -- Communication state fieldTimeout = 0, @@ -324,7 +324,6 @@ function Protocol.reset() Protocol.fieldPopup = nil Protocol.devices = {} - Protocol.devicesRefreshTimeout = 50 Protocol.elrsFlags = 0 Protocol.elrsFlagsInfo = "" @@ -332,6 +331,7 @@ function Protocol.reset() Protocol.lostPackets = nil Protocol.linkstatTimeout = 100 + Protocol.pingTimeout = 0 Protocol.fieldTimeout = 0 Protocol.fieldChunk = 0 @@ -878,20 +878,25 @@ function Protocol.tick() -- Auto-discover other devices when link transitions to connected local connected = Protocol.isConnected() if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) - Protocol.devicesRefreshTimeout = getTime() + 100 + Protocol.pingTimeout = 0 end Protocol.wasConnected = connected local time = getTime() + if time > Protocol.pingTimeout then + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + if #Protocol.devices == 0 then + Protocol.pingTimeout = time + 100 -- 1s fast discovery + else + Protocol.pingTimeout = time + 500 -- 5s + end + end + if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end - elseif time > Protocol.devicesRefreshTimeout and #Protocol.devices == 0 then - Protocol.devicesRefreshTimeout = time + 100 - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) elseif time > Protocol.linkstatTimeout then if Protocol.deviceIsELRS_TX then Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) From a9613042cf472f612574edab6bd2dd396a8616ac Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 09:22:32 +0200 Subject: [PATCH 032/218] no periodic ping --- color/SCRIPTS/TOOLS/expresslrs.lua | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua index d074365..f6aa501 100644 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ b/color/SCRIPTS/TOOLS/expresslrs.lua @@ -313,6 +313,10 @@ function Protocol.push(command, data) return crossfireTelemetryPush(command, data) end +function Protocol.pingDevices() + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) +end + -- Check connection state from elrsFlags function Protocol.isConnected() return bit32.btest(Protocol.elrsFlags, 1) @@ -869,24 +873,18 @@ function Protocol.poll() end function Protocol.tick() - -- Auto-discover other devices when link transitions to connected + -- Ping on connection transition (device may have changed) local connected = Protocol.isConnected() - if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then - Protocol.pingTimeout = 0 + if connected and not Protocol.wasConnected then + Protocol.pingDevices() end Protocol.wasConnected = connected local time = getTime() - -- Ping the radio transmitter to discover other devices - -- We do this every 3 seconds to ensure we always have the latest device list - -- On initial connection, we ping immediately to discover other devices - if time > Protocol.pingTimeout then - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) - if #Protocol.devices == 0 then - Protocol.pingTimeout = time + 100 -- 1s fast discovery - else - Protocol.pingTimeout = time + 500 -- 5s - end + -- Periodic ping for initial device discovery + if #Protocol.devices == 0 and time > Protocol.pingTimeout then + Protocol.pingDevices() + Protocol.pingTimeout = time + 100 -- 1s end if Protocol.fieldPopup then From 7c9f103bb148d70db55602c9b4cc024ee8f20be5 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 09:23:02 +0200 Subject: [PATCH 033/218] no periodic ping --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua index 4854ea2..d0533cb 100644 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua @@ -125,8 +125,7 @@ function App.handleBack() Protocol.allocateFields() Protocol.reloadAllFields() end - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, - { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + Protocol.pingDevices() else local entry = Navigation.goBack() if entry then @@ -351,6 +350,10 @@ function Protocol.push(command, data) return crossfireTelemetryPush(command, data) end +function Protocol.pingDevices() + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) +end + function Protocol.isConnected() return bit32.btest(Protocol.elrsFlags, 1) end @@ -875,21 +878,18 @@ function Protocol.poll() end function Protocol.tick() - -- Auto-discover other devices when link transitions to connected + -- Ping on connection transition (device may have changed) local connected = Protocol.isConnected() - if connected and not Protocol.wasConnected and #Protocol.devices <= 1 then - Protocol.pingTimeout = 0 + if connected and not Protocol.wasConnected then + Protocol.pingDevices() end Protocol.wasConnected = connected local time = getTime() - if time > Protocol.pingTimeout then - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) - if #Protocol.devices == 0 then - Protocol.pingTimeout = time + 100 -- 1s fast discovery - else - Protocol.pingTimeout = time + 500 -- 5s - end + -- Periodic ping for initial device discovery + if #Protocol.devices == 0 and time > Protocol.pingTimeout then + Protocol.pingDevices() + Protocol.pingTimeout = time + 100 -- 1s end if Protocol.fieldPopup then From 63896998c329bb3a7aaac3d62a60ddc88f50b7b1 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 14:34:51 +0200 Subject: [PATCH 034/218] migrate to unified lua for BW & Color lcd radios --- blackwhite/SCRIPTS/TOOLS/elrsbw.lua | 1492 ------------- color/SCRIPTS/TOOLS/expresslrs.lua | 1970 ----------------- {color => src}/SCRIPTS/ELRS/crsf.lua | 0 src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 205 ++ src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua | 82 + src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 737 ++++++ src/SCRIPTS/TOOLS/ExpressLRS/shim.lua | 27 + src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 569 +++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 1066 +++++++++ .../WIDGETS/ELRSTelemetry/loadable.lua | 0 {color => src}/WIDGETS/ELRSTelemetry/main.lua | 0 .../WIDGETS/ELRSTelemetry/ui/hd.lua | 0 .../WIDGETS/ELRSTelemetry/ui/portrait.lua | 0 .../WIDGETS/ELRSTelemetry/ui/sd.lua | 0 .../WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 0 .../WIDGETS/ELRSTelemetry/ui/small.lua | 0 .../WIDGETS/ELRSTelemetry/ui/topbar.lua | 0 .../WIDGETS/ELRSVTXAdmin/loadable.lua | 0 {color => src}/WIDGETS/ELRSVTXAdmin/main.lua | 0 src/WIDGETS/ELRSVTXAdmin/presets.txt | 10 + {color => src}/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 0 .../WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 0 {color => src}/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 0 .../WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 0 .../WIDGETS/ELRSVTXAdmin/ui/small.lua | 0 .../WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 0 .../SCRIPTS/CRSFSimulator/csrfsimulator.lua | 0 27 files changed, 2696 insertions(+), 3462 deletions(-) delete mode 100644 blackwhite/SCRIPTS/TOOLS/elrsbw.lua delete mode 100644 color/SCRIPTS/TOOLS/expresslrs.lua rename {color => src}/SCRIPTS/ELRS/crsf.lua (100%) create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/main.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/shim.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua rename {color => src}/WIDGETS/ELRSTelemetry/loadable.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/main.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/hd.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/portrait.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/sd.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/sd_tall.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/small.lua (100%) rename {color => src}/WIDGETS/ELRSTelemetry/ui/topbar.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/loadable.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/main.lua (100%) create mode 100644 src/WIDGETS/ELRSVTXAdmin/presets.txt rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/hd.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/portrait.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/sd.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/small.lua (100%) rename {color => src}/WIDGETS/ELRSVTXAdmin/ui/topbar.lua (100%) rename {global => test}/SCRIPTS/CRSFSimulator/csrfsimulator.lua (100%) diff --git a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua b/blackwhite/SCRIPTS/TOOLS/elrsbw.lua deleted file mode 100644 index d0533cb..0000000 --- a/blackwhite/SCRIPTS/TOOLS/elrsbw.lua +++ /dev/null @@ -1,1492 +0,0 @@ --- TNS|ELRBW|TNE ----- ######################################################################### ----- # # ----- # Copyright (C) OpenTX, adapted for ExpressLRS # ------# # ----- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ----- # # ----- # BW version for EdgeTX (no LVGL required) # ----- ######################################################################### -local VERSION = "r1 BW" - --- ============================================================================ --- Compat Layer: table.concat polyfill for BW radios --- ============================================================================ - -local tableConcat -if table and table.concat then - tableConcat = table.concat -else - tableConcat = function(t, sep, i, j) - i = i or 1 - j = j or #t - if i > j then - return "" - end - local r = t[i] or "" - for k = i + 1, j do - if sep then - r = r .. sep - end - r = r .. (t[k] or "") - end - return r - end -end - --- ============================================================================ --- Forward declarations for modules (needed for cross-references) --- ============================================================================ - -local Navigation -local Protocol -local UI - --- Popup compatibility wrapper (set in UI.init) -local popupCompat - --- ============================================================================ --- App Module: Application state and coordinators --- ============================================================================ - -local App = { - -- Module check - crsfModuleChecked = false, - crsfModuleFound = false, - - -- Exit - shouldExit = false, -} - --- Reset App to initial values -function App.reset() - App.crsfModuleChecked = false - App.crsfModuleFound = false - App.shouldExit = false - Protocol.reset() -end - --- Module check (caches result to avoid repeated checks) -function App.checkCrsfModule() - if App.crsfModuleChecked then - return App.crsfModuleFound - end - - App.crsfModuleChecked = true - App.crsfModuleFound = Protocol.hasCrsfModule() - return App.crsfModuleFound -end - ---- Active device announced/re-announced. Resets navigation (field tree rebuilding). -function App.loadDevice(device) - if Protocol.setDevice(device) then - Navigation.reset() - UI.lineIndex = 1 - UI.pageOffset = 0 - UI.invalidate() - end -end - ---- User picked a different device from "Other Devices" list. ---- Pushes a navigation entry so Back returns to previous device. -function App.userSwitchDevice(deviceId) - local device = Protocol.getDevice(deviceId) - if not device then - return - end - local prevDeviceId = Protocol.deviceId - if Protocol.setDevice(device) then - Navigation.openDevice(device.name, prevDeviceId) - UI.lineIndex = 1 - UI.pageOffset = 0 - UI.invalidate() - end -end - --- Coordinator: opens a folder, loads its children, and refreshes UI -function App.openFolder(folderId, folderName) - Navigation.openFolder(folderId, folderName) - Protocol.loadFolderChildren(folderId) - UI.lineIndex = 1 - UI.pageOffset = 0 - return UI.invalidate() -end - --- Back button handler: navigate back or reload at root -function App.handleBack() - if Navigation.isAtRoot() then - -- At root: reload everything (like original elrs.lua) - if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then - local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) - if txDevice then - App.loadDevice(txDevice) - end - else - Protocol.allocateFields() - Protocol.reloadAllFields() - end - Protocol.pingDevices() - else - local entry = Navigation.goBack() - if entry then - UI.lineIndex = entry.li or 1 - UI.pageOffset = entry.po or 0 - if entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then - local prevDevice = Protocol.getDevice(entry.prevDeviceId) - if prevDevice then - Protocol.setDevice(prevDevice) - end - end - end - end - UI.invalidate() -end - --- ============================================================================ --- Navigation Module: Folder navigation stack and methods --- ============================================================================ - -Navigation = { - stack = {}, - -- Navigation entry type constants (integers to save RAM vs strings) - TYPE_FOLDER = 0, - TYPE_DEVICE = 1, - -- Synthetic folder IDs - FOLDER_OTHER_DEVICES = -1, -} - -function Navigation.getCurrent() - local top = Navigation.stack[#Navigation.stack] - return top and top.id or nil -end - -function Navigation.isAtRoot() - return #Navigation.stack == 0 -end - -function Navigation.hasDeviceEntry() - for _, entry in ipairs(Navigation.stack) do - if entry.type == Navigation.TYPE_DEVICE then - return true - end - end - return false -end - -function Navigation.openFolder(folderId, folderName) - local baseName = folderName - if folderName then - baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName - end - Navigation.stack[#Navigation.stack + 1] = { - type = Navigation.TYPE_FOLDER, - id = folderId, - name = baseName, - li = UI.lineIndex, - po = UI.pageOffset, - } -end - -function Navigation.openDevice(deviceName, prevDeviceId) - Navigation.stack[#Navigation.stack + 1] = { - type = Navigation.TYPE_DEVICE, - id = nil, - name = deviceName, - prevDeviceId = prevDeviceId, - li = UI.lineIndex, - po = UI.pageOffset, - } -end - -function Navigation.goBack() - if #Navigation.stack > 0 then - local entry = Navigation.stack[#Navigation.stack] - Navigation.stack[#Navigation.stack] = nil - return entry - end - return nil -end - -function Navigation.reset() - Navigation.stack = {} -end - --- ============================================================================ --- Protocol Module: CRSF constants, parsing, and field operations --- (Ported from expresslrs.lua with concat polyfill and collectgarbage) --- ============================================================================ - -Protocol = { - -- EdgeTX module type for CRSF/ELRS - MODULE_TYPE_CROSSFIRE = 5, - - -- CRSF Field Type Constants - CRSF = { - UINT8 = 0, - INT8 = 1, - UINT16 = 2, - INT16 = 3, - UINT32 = 4, - INT32 = 5, - UINT64 = 6, - INT64 = 7, - FLOAT = 8, - TEXT_SELECTION = 9, - STRING = 10, - FOLDER = 11, - INFO = 12, - COMMAND = 13, - -- Internal/extended types (not in official CRSF protocol) - DEVICE = 15, - DEVICE_FOLDER = 16, - - -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, - FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, - - -- Addresses - ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, - - -- ELRS identification - ELRS_SERIAL_ID = 0x454C5253, - - -- ELRS flags - ELRS_FLAGS_STATUS_MASK = 0x03, - ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, - - -- Command steps - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, - CMD_ASKCONFIRM = 3, - CMD_CONFIRMED = 4, - CMD_CANCEL = 5, - CMD_QUERY = 6, - }, - - -- Handlers dispatch table (populated after function definitions) - handlers = {}, - - -- Device identity - deviceId = 0xEE, - handsetId = 0xEF, - deviceName = nil, - deviceIsELRS_TX = nil, - - -- Fields collection - fields = {}, - fieldsCount = 0, - fieldPopup = nil, - - -- Devices collection - devices = {}, - - -- Status/flags - elrsFlags = 0, - elrsFlagsInfo = "", - receivedPackets = nil, - lostPackets = nil, - - -- Protocol timing - linkstatTimeout = 100, - pingTimeout = 0, - - -- Communication state - fieldTimeout = 0, - fieldChunk = 0, - fieldData = nil, - loadQueue = {}, - expectChunksRemain = -1, - backgroundLoading = false, - - -- Connection transition tracking - wasConnected = false, -} - --- Reset Protocol state -function Protocol.reset() - Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER - Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA - Protocol.deviceName = nil - Protocol.deviceIsELRS_TX = nil - - Protocol.fields = {} - Protocol.fieldsCount = 0 - Protocol.fieldPopup = nil - - Protocol.devices = {} - - Protocol.elrsFlags = 0 - Protocol.elrsFlagsInfo = "" - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - - Protocol.linkstatTimeout = 100 - Protocol.pingTimeout = 0 - - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue = {} - Protocol.expectChunksRemain = -1 - Protocol.backgroundLoading = false - Protocol.wasConnected = false -end - --- Default telemetry wrappers (replaced by setMock in simulator) -function Protocol.pop() - return crossfireTelemetryPop() -end - -function Protocol.push(command, data) - return crossfireTelemetryPush(command, data) -end - -function Protocol.pingDevices() - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) -end - -function Protocol.isConnected() - return bit32.btest(Protocol.elrsFlags, 1) -end - -function Protocol.fieldResponseTimeout() - return Protocol.deviceIsELRS_TX and 50 or 500 -end - -function Protocol.hasCrsfModule() - for modIdx = 0, 1 do - local mod = model.getModule(modIdx) - if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then - return true - end - end - return false -end - -function Protocol.setDevice(device) - if not device then - return false - end - if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then - return false - end - - Protocol.deviceId = device.id - Protocol.elrsFlags = 0 - Protocol.deviceName = device.name - Protocol.fieldsCount = device.fieldCount - Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER - - Protocol.allocateFields() - Protocol.reloadAllFields() - return true -end - --- ============================================================================ --- Protocol: Field management functions --- ============================================================================ - -function Protocol.allocateFields() - Protocol.fields = {} - Protocol.fields[0] = {} - for i = 1, Protocol.fieldsCount do - Protocol.fields[i] = {} - end -end - -function Protocol.isFolderLoaded(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return false - end - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if not child or not child.name or child.nameStale then - return false - end - end - return true -end - -function Protocol.getFolderLoadProgress(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return nil - end - local total = #folder.children - local loaded = 0 - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name then - loaded = loaded + 1 - end - end - return loaded, total -end - -function Protocol.reloadAllFields() - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue = {} - Protocol.loadQueue[1] = 0 -end - -function Protocol.getFieldsInFolder(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return {} - end - local result = {} - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name and not child.hidden then - result[#result + 1] = child - end - end - return result -end - -function Protocol.getDevice(id) - for _, device in ipairs(Protocol.devices) do - if device.id == id then - return device - end - end -end - -function Protocol.reloadCurField(field) - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id -end - -function Protocol.loadFolderChildren(folderId) - local folder = Protocol.fields[folderId] - if not folder or not folder.children then - return - end - for i = #folder.children, 1, -1 do - local childId = folder.children[i] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - -function Protocol.startBackgroundLoad() - Protocol.backgroundLoading = true - for i = 1, #Protocol.fields do - local field = Protocol.fields[i] - if field.type == Protocol.CRSF.FOLDER and field.children then - for j = #field.children, 1, -1 do - local childId = field.children[j] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - --- ============================================================================ --- Protocol: Field data helpers --- ============================================================================ - -function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) - local r = last or (isOpts and {}) - local optParts = {} - local vcnt = 0 - repeat - local b = data[offset] - offset = offset + 1 - - if not last then - if r and (b == 59 or b == 0) then - r[#r + 1] = tableConcat(optParts) - if #optParts > 0 then - vcnt = vcnt + 1 - optParts = {} - end - elseif b ~= 0 then - optParts[#optParts + 1] = ({ - [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), - [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) - })[b] or string.char(b) - end - end - until b == 0 - - return (r or tableConcat(optParts)), offset, vcnt -end - -function Protocol.fieldGetValue(data, offset, size) - local result = 0 - for i = 0, size - 1 do - result = bit32.lshift(result, 8) + data[offset + i] - end - return result -end - --- ============================================================================ --- Protocol: Field load functions --- ============================================================================ - -local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = Protocol.fieldGetValue(data, offset, size) - field.min = Protocol.fieldGetValue(data, offset + size, size) - field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) - field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) - if size ~= 1 then - field.size = size - end -end - -local function fieldUnsignedToSigned(field, size) - local bandval = bit32.lshift(0x80, (size - 1) * 8) - field.value = field.value - bit32.band(field.value, bandval) * 2 - field.min = field.min - bit32.band(field.min, bandval) * 2 - field.max = field.max - bit32.band(field.max, bandval) * 2 -end - -local function fieldSignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedToSigned(field, size) - field.size = -size -end - -function Protocol.fieldIntLoad(field, data, offset) - local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad - return loadFn(field, data, offset, math.floor(field.type / 2) + 1) -end - -function Protocol.fieldFloatLoad(field, data, offset) - fieldSignedLoad(field, data, offset, 4, 21) - field.prec = data[offset + 16] - if field.prec > 3 then - field.prec = 3 - end - field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit - field.prec = 10 ^ field.prec -end - -function Protocol.fieldTextSelLoad(field, data, offset) - local vcnt - local cached = field.dirty == nil and field.values - field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) - if not cached then - field.disabled = vcnt <= 1 - end - field.value = data[offset] - field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) - field.dirty = nil -end - -function Protocol.fieldStringLoad(field, data, offset) - field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) - if #data >= offset then - field.maxlen = data[offset] - end -end - -function Protocol.fieldCommandLoad(field, data, offset) - field.status = data[offset] - field.timeout = data[offset + 1] - field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) - if field.status == Protocol.CRSF.CMD_IDLE then - Protocol.fieldPopup = nil - end -end - -function Protocol.fieldFolderLoad(field, data, offset) - field.children = {} - while data[offset] and data[offset] ~= 0xFF do - field.children[#field.children + 1] = data[offset] - offset = offset + 1 - end -end - --- ============================================================================ --- Protocol: Field save functions --- ============================================================================ - -function Protocol.fieldIntSave(field) - local value = field.value - local size = field.size or 1 - if size < 0 then - size = -size - if value < 0 then - value = bit32.lshift(0x100, (size - 1) * 8) + value - end - end - - local frame = { Protocol.deviceId, Protocol.handsetId, field.id } - for i = size - 1, 0, -1 do - frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 - end - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) -end - --- ============================================================================ --- Protocol: Related fields reload --- ============================================================================ - -function Protocol.reloadParentFolder(field) - if field.parent and Protocol.fields[field.parent] then - Protocol.fields[field.parent].nameStale = true - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent - local minTimeout = getTime() + Protocol.fieldResponseTimeout() - if Protocol.fieldTimeout < minTimeout then - Protocol.fieldTimeout = minTimeout - end - end -end - -function Protocol.reloadRelatedFields(field) - Protocol.reloadParentFolder(field) - - for fieldId = Protocol.fieldsCount, 1, -1 do - local sibling = Protocol.fields[fieldId] - local siblingType = sibling.type or 99 - if fieldId ~= field.id - and sibling.parent == field.parent - and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then - sibling.dirty = true - sibling.name = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId - end - end - - field.dirty = true - field.name = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id - Protocol.fieldTimeout = getTime() + 20 - Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 -end - -function Protocol.handleCommandSave(field) - Protocol.reloadCurField(field) - - if field.status ~= nil then - if field.status < Protocol.CRSF.CMD_CONFIRMED then - field.status = Protocol.CRSF.CMD_CLICK - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) - Protocol.fieldPopup = field - Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE - Protocol.fieldTimeout = getTime() + field.timeout - end - end -end - -function Protocol.commandConfirm() - if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED - end -end - -function Protocol.commandCancel() - if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) - Protocol.fieldPopup = nil - end -end - --- ============================================================================ --- Protocol: Handlers dispatch table --- ============================================================================ - -Protocol.handlers = { - [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT32 + 1] = nil, - [Protocol.CRSF.INT32 + 1] = nil, - [Protocol.CRSF.UINT64 + 1] = nil, - [Protocol.CRSF.INT64 + 1] = nil, - [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, - [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, -} - --- ============================================================================ --- Protocol: CRSF message parsing --- ============================================================================ - -function Protocol.parseDeviceInfoMessage(data) - local id = data[2] - local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) - local device = Protocol.getDevice(id) - local isNew = (device == nil) - if isNew then - device = { id = id } - Protocol.devices[#Protocol.devices + 1] = device - end - device.name = newName - device.fieldCount = data[offset + 12] - device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID - return device, isNew -end - -function Protocol.parseParameterInfoMessage(data) - local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] - if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 - return false - end - local field = Protocol.fields[fieldId] - local chunksRemain = data[4] - if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then - return false - end - - local offset - if chunksRemain > 0 or Protocol.fieldChunk > 0 then - Protocol.fieldData = Protocol.fieldData or {} - for i = 5, #data do - Protocol.fieldData[#Protocol.fieldData + 1] = data[i] - data[i] = nil - end - offset = 1 - else - Protocol.fieldData = data - offset = 5 - end - - if chunksRemain > 0 then - Protocol.fieldChunk = Protocol.fieldChunk + 1 - Protocol.expectChunksRemain = chunksRemain - 1 - return false - else - Protocol.loadQueue[#Protocol.loadQueue] = nil - - if #Protocol.fieldData > (offset + 2) then - field.id = fieldId - field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil - field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) - field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil - local cachedName = (not field.nameStale) and field.name or nil - field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) - field.nameStale = nil - local handler = Protocol.handlers[field.type + 1] - if handler and handler.load then - handler.load(field, Protocol.fieldData, offset) - end - if field.min == 0 then - field.min = nil - end - if field.max == 0 then - field.max = nil - end - - if field.type == Protocol.CRSF.FOLDER and field.children - and (fieldId == 0 or Protocol.backgroundLoading) then - for i = #field.children, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] - end - end - end - - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - - return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 - end -end - -function Protocol.parseElrsInfoMessage(data) - if data[2] ~= Protocol.deviceId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 - return - end - - Protocol.lostPackets = data[3] - Protocol.receivedPackets = (data[4] * 256) + data[5] - local newFlags = data[6] - Protocol.elrsFlags = newFlags - Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) -end - -function Protocol.parseElrsV1Message(data) - if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then - return - end - Protocol.elrsV1Detected = true -end - --- ============================================================================ --- Protocol: Main CRSF communication loop --- ============================================================================ - -function Protocol.poll() - local command, data - local targetDevice = nil - local anyNewDevice = false - - repeat - command, data = Protocol.pop() - if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then - local device, isNew = Protocol.parseDeviceInfoMessage(data) - if device.id == Protocol.deviceId then - targetDevice = device - end - if isNew then - anyNewDevice = true - end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then - Protocol.parseParameterInfoMessage(data) - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - elseif Protocol.fieldPopup then - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then - Protocol.parseElrsV1Message(data) - elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then - Protocol.parseElrsInfoMessage(data) - end - until command == nil - - return targetDevice, anyNewDevice -end - -function Protocol.tick() - -- Ping on connection transition (device may have changed) - local connected = Protocol.isConnected() - if connected and not Protocol.wasConnected then - Protocol.pingDevices() - end - Protocol.wasConnected = connected - - local time = getTime() - -- Periodic ping for initial device discovery - if #Protocol.devices == 0 and time > Protocol.pingTimeout then - Protocol.pingDevices() - Protocol.pingTimeout = time + 100 -- 1s - end - - if Protocol.fieldPopup then - if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) - Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout - end - elseif time > Protocol.linkstatTimeout then - if Protocol.deviceIsELRS_TX then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) - else - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - end - Protocol.linkstatTimeout = time + 100 - elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then - if #Protocol.loadQueue > 0 then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) - Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() - else - Protocol.backgroundLoading = false - end - end -end - --- ============================================================================ --- UI Module: BW LCD rendering (extracted from elrs.lua) --- ============================================================================ - -UI = { - -- Cursor/selection state - lineIndex = 1, - pageOffset = 0, - edit = nil, - - -- Visible field list (rebuilt on invalidate) - visibleFields = nil, - - -- Layout constants (set in UI.init) - COL1 = 0, - COL2 = 70, - maxLineIndex = 6, - textSize = 8, - textYoffset = 3, - - -- Redraw state - forceRedraw = true, - folderWasReady = false, - - -- Warning flashing - titleShowWarn = nil, - titleShowWarnTimeout = 100, - - -- Command popup spinner - commandRunningIndicator = 1, -} - -function UI.invalidate() - UI.forceRedraw = true - UI.visibleFields = nil -end - --- ============================================================================ --- UI: Initialization (BW-only LCD setup from elrs.lua setLCDvar) --- ============================================================================ - -function UI.init() - if LCD_W == 212 then - UI.COL2 = 110 - else - UI.COL2 = 70 - end - if LCD_H == 96 then - UI.maxLineIndex = 9 - else - UI.maxLineIndex = 6 - end - UI.COL1 = 0 - UI.textYoffset = 3 - UI.textSize = 8 - - -- Determine popupConfirmation argument count - local _, _, major = getVersion() - if major ~= 1 then - popupCompat = popupConfirmation - else - popupCompat = function(t, m, e) - return popupConfirmation(t, e) - end - end -end - --- ============================================================================ --- UI: Build visible field list for current navigation state --- ============================================================================ - -function UI.buildVisibleFields() - local currentFolder = Navigation.getCurrent() - local vf = {} - - if currentFolder == Navigation.FOLDER_OTHER_DEVICES then - -- Device entries - for _, device in ipairs(Protocol.devices) do - if device.id ~= Protocol.deviceId then - vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.CRSF.DEVICE } - end - end - else - -- Real fields in current folder - local fields = Protocol.getFieldsInFolder(currentFolder) - for _, field in ipairs(fields) do - vf[#vf + 1] = field - end - - -- "Other Devices" entry at root with multiple devices - if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then - vf[#vf + 1] = { name = "Other Devices", type = Protocol.CRSF.DEVICE_FOLDER } - end - end - - UI.visibleFields = vf -end - -function UI.getField(line) - if not UI.visibleFields then - UI.buildVisibleFields() - end - return UI.visibleFields[line] -end - -function UI.getFieldCount() - if not UI.visibleFields then - UI.buildVisibleFields() - end - return #UI.visibleFields -end - --- Total selectable rows: all fields + the back/exit widget -function UI.getSelectableCount() - return UI.getFieldCount() + 1 -end - --- Whether the cursor is on the back/exit widget row -function UI.isOnBackExit() - return UI.lineIndex > UI.getFieldCount() -end - --- The label for the back/exit widget -function UI.getBackExitLabel() - if Navigation.isAtRoot() then - return "-- EXIT (" .. VERSION .. ") --" - else - return "----BACK----" - end -end - --- ============================================================================ --- UI: Field value increment (from elrs.lua incrField, expresslrs.lua incrField) --- ============================================================================ - -function UI.incrField(step) - local field = UI.getField(UI.lineIndex) - if not field then - return - end - local min, max = 0, 0 - if field.type <= Protocol.CRSF.FLOAT then - min = field.min or 0 - max = field.max or 0 - step = (field.step or 1) * step - elseif field.type == Protocol.CRSF.TEXT_SELECTION then - min = 0 - max = #field.values - 1 - end - - local newval = field.value - repeat - newval = newval + step - if newval < min then - newval = min - elseif newval > max then - newval = max - end - - if field.values == nil or #field.values[newval + 1] ~= 0 then - field.value = newval - return - end - until (newval == min or newval == max) -end - --- ============================================================================ --- UI: Field selection navigation (from elrs.lua selectField) --- ============================================================================ - -function UI.selectField(step) - local count = UI.getSelectableCount() - if count == 0 then - return - end - local fieldCount = UI.getFieldCount() - local newLineIndex = UI.lineIndex - repeat - newLineIndex = newLineIndex + step - if newLineIndex <= 0 then - newLineIndex = count - elseif newLineIndex > count then - newLineIndex = 1 - UI.pageOffset = 0 - end - -- Back/exit row is always selectable; for fields, skip unnamed ones - if newLineIndex > fieldCount then - break - end - local field = UI.getField(newLineIndex) - if field and field.name then - break - end - until newLineIndex == UI.lineIndex - UI.lineIndex = newLineIndex - if UI.lineIndex > UI.maxLineIndex + UI.pageOffset then - UI.pageOffset = UI.lineIndex - UI.maxLineIndex - elseif UI.lineIndex <= UI.pageOffset then - UI.pageOffset = UI.lineIndex - 1 - end -end - --- ============================================================================ --- UI: BW field display functions (from elrs.lua) --- ============================================================================ - -local function fieldIntDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, field.value .. field.unit, attr) -end - -local function fieldFloatDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec), attr) -end - -local function fieldTextSelDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. field.unit, attr) -end - -local function fieldStringDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, field.value or "", attr) -end - -local function fieldFolderDisplay(field, y, attr) - lcd.drawText(UI.COL1, y, "> " .. field.name, attr + BOLD) -end - -local function fieldCommandDisplay(field, y, attr) - lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) -end - --- Display handler table: maps field type to display function -local displayHandlers = {} -displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay -displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay -displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay -displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay -displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay -displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay - --- ============================================================================ --- UI: Title bar drawing (from elrs.lua lcd_title_bw) --- ============================================================================ - -function UI.drawTitle() - local barHeight = 9 - local goodBadPkt = "" - if Protocol.receivedPackets then - local state = Protocol.isConnected() and "C" or "-" - goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) - end - - local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) - if not UI.titleShowWarn then - lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) - lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight - 1, SOLID, INVERS) - end - - if loaded and total and total > 0 and loaded < total then - lcd.drawFilledRectangle(UI.COL2, 0, LCD_W, barHeight, GREY_DEFAULT) - lcd.drawGauge(0, 0, UI.COL2, barHeight, loaded, total, 0) - else - lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) - if UI.titleShowWarn then - lcd.drawText(UI.COL1, 1, Protocol.elrsFlagsInfo, INVERS) - else - lcd.drawText(UI.COL1, 1, Protocol.deviceName or "Searching...", INVERS) - end - end -end - --- ============================================================================ --- UI: Warning display (from elrs.lua lcd_warn) --- ============================================================================ - -function UI.drawWarning() - lcd.drawText(UI.COL1, UI.textSize * 2, "Error:") - lcd.drawText(UI.COL1, UI.textSize * 3, Protocol.elrsFlagsInfo) - lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER) -end - --- ============================================================================ --- UI: Event handling (from elrs.lua handleDevicePageEvent, adapted for modules) --- ============================================================================ - -function UI.handleEvent(event) - if UI.getSelectableCount() == 0 then - return - end - - if event == EVT_VIRTUAL_EXIT then - if UI.edit then - UI.edit = nil - local field = UI.getField(UI.lineIndex) - if field and field.id then - Protocol.reloadCurField(field) - end - else - App.handleBack() - end - elseif event == EVT_VIRTUAL_ENTER then - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then - -- Dismiss critical warning - Protocol.elrsFlags = 0 - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) - elseif UI.isOnBackExit() then - if Navigation.isAtRoot() then - App.shouldExit = true - else - App.handleBack() - end - else - local field = UI.getField(UI.lineIndex) - if field and field.name then - local ft = field.type - - if ft == Protocol.CRSF.FOLDER then - App.openFolder(field.id, field.name) - elseif ft == Protocol.CRSF.DEVICE_FOLDER then - App.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") - elseif ft == Protocol.CRSF.DEVICE then - App.userSwitchDevice(field.id) - elseif ft == Protocol.CRSF.COMMAND then - Protocol.handleCommandSave(field) - elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then - -- Editable value fields - UI.edit = not UI.edit - if not UI.edit then - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end - end - end - end - elseif UI.edit then - if event == EVT_VIRTUAL_NEXT then - UI.incrField(1) - elseif event == EVT_VIRTUAL_PREV then - UI.incrField(-1) - end - else - if event == EVT_VIRTUAL_NEXT then - UI.selectField(1) - elseif event == EVT_VIRTUAL_PREV then - UI.selectField(-1) - end - end -end - --- ============================================================================ --- UI: Main page rendering (from elrs.lua runDevicePage) --- ============================================================================ - -function UI.drawPage(event) - UI.handleEvent(event) - - lcd.clear() - UI.drawTitle() - - -- Show "Other Devices" folder by checking device count - -- (handled via visibleFields list) - - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then - UI.drawWarning() - else - local totalCount = UI.getSelectableCount() - for y = 1, UI.maxLineIndex + 1 do - local idx = UI.pageOffset + y - if idx > totalCount then - break - end - local yPos = y * UI.textSize + UI.textYoffset - local isSelected = (UI.lineIndex == idx) - local attr = isSelected and ((UI.edit and BLINK or 0) + INVERS) or 0 - - if idx > UI.getFieldCount() then - -- Back/exit widget row - lcd.drawText(10, yPos, "[" .. UI.getBackExitLabel() .. "]", attr + BOLD) - else - local field = UI.getField(idx) - if field and field.name then - local ft = field.type - -- Draw field name for value/info fields (not folder, command, synthetic) - if ft < Protocol.CRSF.FOLDER or ft == Protocol.CRSF.INFO then - lcd.drawText(UI.COL1, yPos, field.name, 0) - end - -- Draw field value/display - local displayFn = displayHandlers[ft] - if displayFn then - displayFn(field, yPos, attr) - end - end - end - end - end -end - --- ============================================================================ --- UI: Command popup rendering (from elrs.lua runPopupPage) --- ============================================================================ - -function UI.drawPopup(event) - if event == EVT_VIRTUAL_EXIT then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) - Protocol.fieldTimeout = getTime() + 200 - end - - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then - popupCompat(Protocol.fieldPopup.info, "Stopped!", event) - Protocol.reloadAllFields() - Protocol.fieldPopup = nil - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then - local result = popupCompat(Protocol.fieldPopup.info, "PRESS [OK] to confirm", event) - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - if result == "OK" then - Protocol.commandConfirm() - elseif result == "CANCEL" then - Protocol.fieldPopup = nil - end - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then - if Protocol.fieldChunk == 0 then - UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 - end - local result = popupCompat( - Protocol.fieldPopup.info .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", - "Press [RTN] to exit", - event) - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - if result == "CANCEL" then - Protocol.commandCancel() - end - end -end - --- ============================================================================ --- Alert screen (clear screen + title + body messages) --- ============================================================================ - -local function drawAlert(title, msgs) - lcd.clear() - local y = 0 - lcd.drawText(2, y, title, MIDSIZE) - y = y + (UI.textSize * 2) - 2 - for _, msg in ipairs(msgs) do - lcd.drawText(2, y, msg) - y = y + UI.textSize - end -end - --- ============================================================================ --- Mock data for simulator --- ============================================================================ - -local function setMock() - local _, rv = getVersion() - if string.sub(rv, -5) ~= "-simu" then - return - end - local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") - if mockModule == nil then - return - end - local mock = mockModule() - Protocol.pop = mock.pop - Protocol.push = mock.push - Protocol.hasCrsfModule = function() - return mock.moduleFound - end -end - --- ============================================================================ --- Init --- ============================================================================ - -local function init() - UI.init() - setMock() -end - --- ============================================================================ --- Run (main coordinator) --- ============================================================================ - -local function run(event, touchState) - if event == nil then - return 2 - end - - -- Check for CRSF module - if not App.checkCrsfModule() then - drawAlert(" No ExpressLRS", { - " Enable a CRSF Internal", - " or External module in", - " Model settings", - " If module is internal", - " also set Internal RF to", - " CRSF in SYS->Hardware", - }) - return 0 - end - - -- CRSF polling - local targetDevice, anyNewDevice = Protocol.poll() - Protocol.tick() - - -- Check for ELRS 1.x firmware (unsupported) - if Protocol.elrsV1Detected then - drawAlert("Unsupported Firmware", { - "ELRS 1.x firmware detected.", - "Please update to 3.x.", - }) - return 0 - end - - -- Activate the target device if it announced/re-announced this cycle - if targetDevice then - App.loadDevice(targetDevice) - end - -- Refresh UI if a new device appeared - if anyNewDevice then - UI.invalidate() - end - - -- Warning flashing timer - local time = getTime() - if time > UI.titleShowWarnTimeout then - UI.titleShowWarn = (Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil - UI.titleShowWarnTimeout = time + 100 - UI.forceRedraw = true - end - - -- Folder ready detection + GC - local currentFolder = Navigation.getCurrent() - local folderReady = Protocol.isFolderLoaded(currentFolder) - if folderReady and not UI.folderWasReady then - collectgarbage("collect") - UI.invalidate() - if currentFolder == nil and not Protocol.backgroundLoading then - Protocol.startBackgroundLoad() - end - end - UI.folderWasReady = folderReady - - -- Force redraw during loading to show progress bar - if #Protocol.loadQueue > 0 then - UI.forceRedraw = true - end - - -- Render: command popup or normal page - if Protocol.fieldPopup ~= nil then - UI.drawPopup(event) - elseif event ~= 0 or UI.forceRedraw or UI.edit then - UI.drawPage(event) - UI.forceRedraw = false - end - - if App.shouldExit then - return 2 - end - - return 0 -end - --- ============================================================================ --- Return --- ============================================================================ - -return { init = init, run = run } diff --git a/color/SCRIPTS/TOOLS/expresslrs.lua b/color/SCRIPTS/TOOLS/expresslrs.lua deleted file mode 100644 index f6aa501..0000000 --- a/color/SCRIPTS/TOOLS/expresslrs.lua +++ /dev/null @@ -1,1970 +0,0 @@ --- TNS|ExpressLRS LVGL|TNE ----- ######################################################################### ----- # # ----- # Copyright (C) OpenTX, adapted for ExpressLRS # ------# # ----- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ----- # # ----- # LVGL version for EdgeTX 2.11.4+ # ----- ######################################################################### -local VERSION = "r1 LVGL" -local VERSION_CHECK_ENABLED = true - --- ============================================================================ --- App Module: Application state and coordinators --- ============================================================================ - -local App = { - -- Module check - crsfModuleChecked = false, - crsfModuleFound = false, - - -- User acknowledgment - warningDismissed = false, - warningDismissedAt = nil, - - -- Active dialogs - warningDialog = nil, - commandDialog = nil, - - -- Exit - shouldExit = false, -} - --- Forward declarations for modules (needed for cross-references) -local Navigation -local Protocol -local UI -local Dialogs - --- Reset App to initial values -function App.reset() - App.crsfModuleChecked = false - App.crsfModuleFound = false - App.warningDismissed = false - App.warningDismissedAt = nil - App.shouldExit = false - Protocol.reset() -end - --- Module check (caches result to avoid repeated checks) -function App.checkCrsfModule() - if App.crsfModuleChecked then - return App.crsfModuleFound - end - - App.crsfModuleChecked = true - App.crsfModuleFound = Protocol.hasCrsfModule() - return App.crsfModuleFound -end - ---- Active device announced/re-announced. Resets navigation (field tree rebuilding). -function App.loadDevice(device) - if Protocol.setDevice(device) then - Navigation.reset() - UI.invalidate() - end -end - ---- User picked a different device from "Other Devices" list. ---- Pushes a navigation entry so Back returns to previous device. -function App.userSwitchDevice(deviceId) - local device = Protocol.getDevice(deviceId) - if not device then - return - end - local prevDeviceId = Protocol.deviceId - if Protocol.setDevice(device) then - Navigation.openDevice(device.name, prevDeviceId) - UI.invalidate() - end -end - --- Coordinator: opens a folder, loads its children, and refreshes UI -function App.openFolder(folderId, folderName) - Navigation.openFolder(folderId, folderName) - Protocol.loadFolderChildren(folderId) - return UI.invalidate() -end - --- Back button handler: navigate back or show exit confirmation -function App.handleBack() - if Navigation.isAtRoot() then - Dialogs.showConfirm({ - title = "Exit", - message = "Exit ExpressLRS Lua script?", - onConfirm = function() App.shouldExit = true end - }) - else - local entry = Navigation.goBack() - if entry and entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then - local prevDevice = Protocol.getDevice(entry.prevDeviceId) - if prevDevice then - Protocol.setDevice(prevDevice) - end - end - UI.invalidate() - end -end - --- ============================================================================ --- Navigation Module: Folder navigation stack and methods --- ============================================================================ - -Navigation = { - stack = {}, - -- Navigation entry type constants (integers to save RAM vs strings) - TYPE_FOLDER = 0, - TYPE_DEVICE = 1, - -- Synthetic folder IDs - FOLDER_OTHER_DEVICES = -1, -} - -function Navigation.getCurrent() - local top = Navigation.stack[#Navigation.stack] - return top and top.id or nil -- nil if at root (or device root) -end - -function Navigation.isAtRoot() - return #Navigation.stack == 0 -end - --- Check if the user has navigated into a device (for hiding "Other Devices") -function Navigation.hasDeviceEntry() - for _, entry in ipairs(Navigation.stack) do - if entry.type == Navigation.TYPE_DEVICE then - return true - end - end - return false -end - -function Navigation.openFolder(folderId, folderName) - local baseName = folderName - if folderName then - baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName - end - table.insert(Navigation.stack, { type = Navigation.TYPE_FOLDER, id = folderId, name = baseName }) -end - -function Navigation.openDevice(deviceName, prevDeviceId) - -- id=nil means device root; getCurrent() returns nil which shows root fields - table.insert(Navigation.stack, { type = Navigation.TYPE_DEVICE, id = nil, name = deviceName, prevDeviceId = prevDeviceId }) -end - -function Navigation.goBack() - if #Navigation.stack > 0 then - return table.remove(Navigation.stack) - end - return nil -end - -function Navigation.reset() - Navigation.stack = {} -end - --- ============================================================================ --- Protocol Module: CRSF constants, parsing, and field operations --- ============================================================================ - -Protocol = { - -- EdgeTX module type for CRSF/ELRS - MODULE_TYPE_CROSSFIRE = 5, - - -- CRSF Field Type Constants - CRSF = { - UINT8 = 0, - INT8 = 1, - UINT16 = 2, - INT16 = 3, - UINT32 = 4, - INT32 = 5, - UINT64 = 6, - INT64 = 7, - FLOAT = 8, - TEXT_SELECTION = 9, - STRING = 10, - FOLDER = 11, - INFO = 12, - COMMAND = 13, - -- Internal/extended types (not in official CRSF protocol) - BACK_EXIT = 14, - DEVICE = 15, - DEVICE_FOLDER = 16, - - -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, - FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, - - -- Addresses - ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, - - -- ELRS identification - ELRS_SERIAL_ID = 0x454C5253, - - -- ELRS flags: bits 0-1 are status (connected, status1), - -- bits 2-4 are warnings (model match, armed, warning1), - -- bits 5-7 are critical errors (error connected, error baudrate, critical2) - ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only - ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags - - -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, - CMD_ASKCONFIRM = 3, - CMD_CONFIRMED = 4, - CMD_CANCEL = 5, - CMD_QUERY = 6, - }, - - -- Handlers dispatch table (populated after function definitions) - handlers = {}, - - -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua - deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) - handsetId = 0xEF, -- ADDRESS_ELRS_LUA - deviceName = nil, - deviceIsELRS_TX = nil, - - -- Fields collection - fields = {}, - fieldsCount = 0, - fieldPopup = nil, - - -- Devices collection - devices = {}, - - -- Status/flags (parsed from ELRS info messages) - elrsFlags = 0, - elrsFlagsInfo = "", - elrsV1Detected = false, - receivedPackets = nil, - lostPackets = nil, - - -- Protocol timing - linkstatTimeout = 100, - pingTimeout = 0, - - -- Communication state - fieldTimeout = 0, - fieldChunk = 0, - fieldData = nil, - loadQueue = {}, - expectChunksRemain = -1, - backgroundLoading = false, - - -- Connection transition tracking (for auto-discovery on reconnect) - wasConnected = false, -} - --- Reset Protocol state -function Protocol.reset() - -- Device identity - Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER - Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA - Protocol.deviceName = nil - Protocol.deviceIsELRS_TX = nil - - -- Fields collection - Protocol.fields = {} - Protocol.fieldsCount = 0 - Protocol.fieldPopup = nil - - -- Devices collection - Protocol.devices = {} - - -- Status/flags - Protocol.elrsFlags = 0 - Protocol.elrsFlagsInfo = "" - Protocol.elrsV1Detected = false - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - - -- Protocol timing - Protocol.linkstatTimeout = 100 - Protocol.pingTimeout = 0 - - -- Communication state - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue = {} - Protocol.expectChunksRemain = -1 - Protocol.backgroundLoading = false - Protocol.wasConnected = false -end - --- Default telemetry wrappers: delegate to real EdgeTX functions --- When mocking is active, setMock() replaces these with mock implementations -function Protocol.pop() - return crossfireTelemetryPop() -end - -function Protocol.push(command, data) - return crossfireTelemetryPush(command, data) -end - -function Protocol.pingDevices() - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) -end - --- Check connection state from elrsFlags -function Protocol.isConnected() - return bit32.btest(Protocol.elrsFlags, 1) -end - --- Response timeout for PARAMETER_READ (from upstream elrsV3.lua): --- 0.5s for local TX module, 5s for remote devices relayed over air link. -function Protocol.fieldResponseTimeout() - return Protocol.deviceIsELRS_TX and 50 or 500 -end - --- Check if a CRSF-compatible module is available -function Protocol.hasCrsfModule() - for modIdx = 0, 1 do - local mod = model.getModule(modIdx) - if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then - return true - end - end - return false -end - --- Set active device and prepare fields --- Returns true if device changed, false if no change needed -function Protocol.setDevice(device) - if not device then - return false - end - if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then - return false - end - - Protocol.deviceId = device.id - Protocol.elrsFlags = 0 - Protocol.deviceName = device.name - Protocol.fieldsCount = device.fieldCount - Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER - - Protocol.allocateFields() - Protocol.reloadAllFields() - return true -end - --- ============================================================================ --- Protocol: Field management functions --- ============================================================================ - -function Protocol.allocateFields() - Protocol.fields = {} - Protocol.fields[0] = {} -- root folder (field 0) - for i = 1, Protocol.fieldsCount do - Protocol.fields[i] = {} - end -end - --- Check if all children of a folder have been loaded (have names). --- folderId: the folder's field ID, or nil for root (uses field 0). -function Protocol.isFolderLoaded(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return false - end - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if not child or not child.name or child.nameStale then - return false - end - end - return true -end - --- Return load progress for a folder's children as (loaded, total). --- Returns nil if the folder or its children list is unknown yet. -function Protocol.getFolderLoadProgress(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return nil - end - local total = #folder.children - local loaded = 0 - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name then - loaded = loaded + 1 - end - end - return loaded, total -end - -function Protocol.reloadAllFields() - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue = {} - -- Start by loading only field 0 (root folder). - -- Its response contains child IDs; only root children are auto-queued. - -- Subfolder children are loaded on-demand via loadFolderChildren(). - Protocol.loadQueue[1] = 0 -end - --- Parameterized: takes folderId instead of accessing Navigation -function Protocol.getFieldsInFolder(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return {} - end - local result = {} - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name and not child.hidden then - result[#result + 1] = child - end - end - return result -end - -function Protocol.getDevice(id) - for _, device in ipairs(Protocol.devices) do - if device.id == id then - return device - end - end -end - -function Protocol.reloadCurField(field) - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id -end - --- Queue unloaded children of a folder for on-demand loading. --- Called when the user navigates into a subfolder whose contents --- haven't been fetched yet. -function Protocol.loadFolderChildren(folderId) - local folder = Protocol.fields[folderId] - if not folder or not folder.children then - return - end - for i = #folder.children, 1, -1 do - local childId = folder.children[i] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - --- Queue all unloaded subfolder children for background preloading. --- Called once after the root screen finishes loading so that navigating --- into subfolders is instant (or near-instant). -function Protocol.startBackgroundLoad() - Protocol.backgroundLoading = true - for i = 1, #Protocol.fields do - local field = Protocol.fields[i] - if field.type == Protocol.CRSF.FOLDER and field.children then - for j = #field.children, 1, -1 do - local childId = field.children[j] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - --- ============================================================================ --- Protocol: Field data helpers --- ============================================================================ - -function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) - local r = last or (isOpts and {}) - local optParts = {} - local vcnt = 0 - repeat - local b = data[offset] - offset = offset + 1 - - if not last then - if r and (b == 59 or b == 0) then - r[#r + 1] = table.concat(optParts) - if #optParts > 0 then - vcnt = vcnt + 1 - optParts = {} - end - elseif b ~= 0 then - -- Translate legacy OpenTX arrow bytes (0xC0/0xC1) from ELRS firmware - -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs - optParts[#optParts + 1] = ({ - [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), - [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) - })[b] or string.char(b) - end - end - until b == 0 - - return (r or table.concat(optParts)), offset, vcnt -end - -function Protocol.fieldGetValue(data, offset, size) - local result = 0 - for i = 0, size - 1 do - result = bit32.lshift(result, 8) + data[offset + i] - end - return result -end - --- ============================================================================ --- Protocol: Field load functions --- ============================================================================ - -local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = Protocol.fieldGetValue(data, offset, size) - field.min = Protocol.fieldGetValue(data, offset + size, size) - field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) - field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) - if size ~= 1 then - field.size = size - end -end - -local function fieldUnsignedToSigned(field, size) - local bandval = bit32.lshift(0x80, (size - 1) * 8) - field.value = field.value - bit32.band(field.value, bandval) * 2 - field.min = field.min - bit32.band(field.min, bandval) * 2 - field.max = field.max - bit32.band(field.max, bandval) * 2 -end - -local function fieldSignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedToSigned(field, size) - field.size = -size -end - -function Protocol.fieldIntLoad(field, data, offset) - local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad - return loadFn(field, data, offset, math.floor(field.type / 2) + 1) -end - -function Protocol.fieldFloatLoad(field, data, offset) - fieldSignedLoad(field, data, offset, 4, 21) - field.prec = data[offset + 16] - if field.prec > 3 then - field.prec = 3 - end - field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit - field.prec = 10 ^ field.prec -end - -function Protocol.fieldTextSelLoad(field, data, offset) - local vcnt - local cached = field.dirty == nil and field.values - field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) - if not cached then - field.disabled = vcnt <= 1 - end - field.value = data[offset] - field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) - field.dirty = nil -end - -function Protocol.fieldStringLoad(field, data, offset) - field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) - if #data >= offset then - field.maxlen = data[offset] - end -end - -function Protocol.fieldCommandLoad(field, data, offset) - field.status = data[offset] - field.timeout = data[offset + 1] - field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) - if field.status == Protocol.CRSF.CMD_IDLE then - Protocol.fieldPopup = nil - end -end - -function Protocol.fieldFolderLoad(field, data, offset) - -- Parse child ID list (terminated by 0xFF) - field.children = {} - while data[offset] and data[offset] ~= 0xFF do - field.children[#field.children + 1] = data[offset] - offset = offset + 1 - end -end - --- ============================================================================ --- Protocol: Field save functions --- ============================================================================ - -function Protocol.fieldIntSave(field) - local value = field.value - local size = field.size or 1 - if size < 0 then - size = -size - if value < 0 then - value = bit32.lshift(0x100, (size - 1) * 8) + value - end - end - - local frame = { Protocol.deviceId, Protocol.handsetId, field.id } - for i = size - 1, 0, -1 do - frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 - end - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) -end - --- ============================================================================ --- Protocol: Related fields reload (for value changes) --- ============================================================================ - -function Protocol.reloadParentFolder(field) - if field.parent and Protocol.fields[field.parent] then - -- Mark stale instead of clearing name to nil. This keeps the old folder - -- name visible in the UI while the reload is in-flight, avoiding a gap - -- where the folder disappears (getFieldsInFolder skips nil-named fields). - Protocol.fields[field.parent].nameStale = true - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent - -- Delay the READ so it doesn't race the WRITE on the CRSF bus. - -- Firmware needs time to process the write and update folder names. - -- Uses the same device-dependent timeout as poll() PARAMETER_READ. - local minTimeout = getTime() + Protocol.fieldResponseTimeout() - if Protocol.fieldTimeout < minTimeout then - Protocol.fieldTimeout = minTimeout - end - end -end - -function Protocol.reloadRelatedFields(field) - Protocol.reloadParentFolder(field) - - for fieldId = Protocol.fieldsCount, 1, -1 do - local sibling = Protocol.fields[fieldId] - local siblingType = sibling.type or 99 - if fieldId ~= field.id - and sibling.parent == field.parent - and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then - sibling.dirty = true - sibling.name = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId - end - end - - field.dirty = true - field.name = nil - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id - Protocol.fieldTimeout = getTime() + 20 - Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 -end - -function Protocol.handleCommandSave(field) - Protocol.reloadCurField(field) - - if field.status ~= nil then - if field.status < Protocol.CRSF.CMD_CONFIRMED then - field.status = Protocol.CRSF.CMD_CLICK - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) - Protocol.fieldPopup = field - Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE - Protocol.fieldTimeout = getTime() + field.timeout - end - end -end - -function Protocol.commandConfirm() - if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED - end -end - -function Protocol.commandCancel() - if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) - Protocol.fieldPopup = nil - end -end - --- ============================================================================ --- Protocol: Handlers dispatch table (no forward declarations needed!) --- ============================================================================ - -Protocol.handlers = { - [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT32 + 1] = nil, - [Protocol.CRSF.INT32 + 1] = nil, - [Protocol.CRSF.UINT64 + 1] = nil, - [Protocol.CRSF.INT64 + 1] = nil, - [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, - [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, - -- DEVICE and DEVICE_FOLDER are synthetic types, handled by UI directly -} - --- ============================================================================ --- Protocol: CRSF message parsing (return signals, no Nav/UI knowledge) --- ============================================================================ - -function Protocol.parseDeviceInfoMessage(data) - local id = data[2] - local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) - local device = Protocol.getDevice(id) - local isNew = (device == nil) - if isNew then - device = { id = id } - Protocol.devices[#Protocol.devices + 1] = device - end - device.name = newName - device.fieldCount = data[offset + 12] - device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID - return device, isNew -end - -function Protocol.parseParameterInfoMessage(data) - local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] - if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 - return false - end - local field = Protocol.fields[fieldId] - local chunksRemain = data[4] - if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then - return false - end - - local offset - if chunksRemain > 0 or Protocol.fieldChunk > 0 then - Protocol.fieldData = Protocol.fieldData or {} - for i = 5, #data do - Protocol.fieldData[#Protocol.fieldData + 1] = data[i] - data[i] = nil - end - offset = 1 - else - Protocol.fieldData = data - offset = 5 - end - - if chunksRemain > 0 then - Protocol.fieldChunk = Protocol.fieldChunk + 1 - Protocol.expectChunksRemain = chunksRemain - 1 - return false - else - Protocol.loadQueue[#Protocol.loadQueue] = nil - - if #Protocol.fieldData > (offset + 2) then - field.id = fieldId - field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil - field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) - field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil - local cachedName = (not field.nameStale) and field.name or nil - field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) - field.nameStale = nil - local handler = Protocol.handlers[field.type + 1] - if handler and handler.load then - handler.load(field, Protocol.fieldData, offset) - end - if field.min == 0 then - field.min = nil - end - if field.max == 0 then - field.max = nil - end - - -- Auto-queue children for root folder (field 0) and during background preloading. - -- Subfolder children are otherwise loaded on-demand when user navigates into them. - if field.type == Protocol.CRSF.FOLDER and field.children - and (fieldId == 0 or Protocol.backgroundLoading) then - for i = #field.children, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] - end - end - end - - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - - return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 - end -end - -function Protocol.parseElrsInfoMessage(data) - if data[2] ~= Protocol.deviceId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 - return - end - - Protocol.lostPackets = data[3] - Protocol.receivedPackets = (data[4] * 256) + data[5] - local newFlags = data[6] - Protocol.elrsFlags = newFlags - Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) -end - -function Protocol.parseElrsV1Message(data) - if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then - return - end - Protocol.elrsV1Detected = true -end - --- ============================================================================ --- Protocol: Main CRSF communication loop --- ============================================================================ - -function Protocol.poll() - local command, data - local targetDevice = nil - local anyNewDevice = false - - repeat - command, data = Protocol.pop() - if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then - local device, isNew = Protocol.parseDeviceInfoMessage(data) - if device.id == Protocol.deviceId then - targetDevice = device - end - if isNew then - anyNewDevice = true - end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then - Protocol.parseParameterInfoMessage(data) - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - elseif Protocol.fieldPopup then - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then - Protocol.parseElrsV1Message(data) - elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then - Protocol.parseElrsInfoMessage(data) - end - until command == nil - - return targetDevice, anyNewDevice -end - -function Protocol.tick() - -- Ping on connection transition (device may have changed) - local connected = Protocol.isConnected() - if connected and not Protocol.wasConnected then - Protocol.pingDevices() - end - Protocol.wasConnected = connected - - local time = getTime() - -- Periodic ping for initial device discovery - if #Protocol.devices == 0 and time > Protocol.pingTimeout then - Protocol.pingDevices() - Protocol.pingTimeout = time + 100 -- 1s - end - - if Protocol.fieldPopup then - if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) - Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout - end - elseif time > Protocol.linkstatTimeout then - if Protocol.deviceIsELRS_TX then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) - else - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - end - Protocol.linkstatTimeout = time + 100 - elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then - if #Protocol.loadQueue > 0 then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) - Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() - else - Protocol.backgroundLoading = false - end - end -end - --- ============================================================================ --- UI Module: LVGL page/widget building --- ============================================================================ - -UI = { - currentPage = nil, - uiBuilt = false, - folderWasReady = false, -} - --- Called by Navigation/run loop to trigger UI rebuild -function UI.invalidate() - UI.uiBuilt = false -end - -function UI.getSubtitle() - -- When inside a folder, show current menu item name - if not Navigation.isAtRoot() then - local top = Navigation.stack[#Navigation.stack] - local path = top.name or "" - - -- Append loading indicator only when current folder's children aren't ready - local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) - if loaded and loaded < total then - path = path .. string.format(" • Loading %d%%", math.floor(loaded / total * 100)) - end - - return path - end - - -- At root level, show loading status only during initial load (not background) - local loaded, total = Protocol.getFolderLoadProgress(nil) - if loaded and loaded < total and Protocol.fieldsCount > 0 then - return string.format("Loading %d%%", math.floor(loaded / total * 100)) - end - - -- At root level, show link stats (and warning if present) - local subtitle = "" - if Protocol.receivedPackets then - local state = Protocol.isConnected() and "Connected" or "No link" - subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) - end - - -- Add warning indicator if warning/error flags are set (bits 2+, above status bits 0-1) - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then - if subtitle ~= "" then - subtitle = subtitle .. " • " .. Protocol.elrsFlagsInfo - else - subtitle = Protocol.elrsFlagsInfo - end - end - - return subtitle -end - -function UI.incrField(field, step) - local min, max = 0, 0 - if field.type <= Protocol.CRSF.FLOAT then - min = field.min or 0 - max = field.max or 0 - step = (field.step or 1) * step - elseif field.type == Protocol.CRSF.TEXT_SELECTION then - min = 0 - max = #field.values - 1 - end - - local newval = field.value - repeat - newval = newval + step - if newval < min then - newval = min - elseif newval > max then - newval = max - end - - if field.values == nil or #field.values[newval + 1] ~= 0 then - field.value = newval - return - end - until (newval == min or newval == max) -end - --- Check if field has only Off/On values -function UI.isBooleanField(field) - if not field.values or #field.values ~= 2 then - return false - end - return field.values[1] == "Off" and field.values[2] == "On" -end - --- ============================================================================ --- UI: Widget creators --- ============================================================================ - --- Narrow screens (e.g. FlySky EL18 portrait, PA01) need more room for controls -local IS_NARROW = LCD_W < 400 -local LABEL_PCT = IS_NARROW and 42 or 50 -local CTRL_PCT = 100 - LABEL_PCT - -function UI.createChoiceRow(pg, field) - local row = pg:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0 - }) - - local labelRect = row:rectangle({ - w = lvgl.PERCENT_SIZE + LABEL_PCT, - h = lvgl.UI_ELEMENT_HEIGHT, - thickness = 0, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - text = field.name or "", - color = COLOR_THEME_PRIMARY1 - } - } - }) - - local ctrlRect = row:rectangle({ - w = lvgl.PERCENT_SIZE + CTRL_PCT, - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - align = LEFT + VCENTER - }) - - if UI.isBooleanField(field) then - ctrlRect:toggle({ - get = function() return field.value or 0 end, - set = function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end, - active = function() return not field.disabled end - }) - else - local filteredValues = {} - local origToFiltered = {} - local filteredToOrig = {} - for i, v in ipairs(field.values or {}) do - if v ~= "" then - filteredValues[#filteredValues + 1] = v - origToFiltered[i - 1] = #filteredValues - filteredToOrig[#filteredValues] = i - 1 - end - end - ctrlRect:choice({ - values = filteredValues, - get = function() return origToFiltered[field.value or 0] or 1 end, - set = function(val) - field.value = filteredToOrig[val] or 0 - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end, - active = function() return not field.disabled end - }) - end - - if field.unit and field.unit ~= "" then - ctrlRect:box({ - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - text = " " .. field.unit, - } - } - }) - end -end - -function UI.createNumberRow(pg, field) - local displayFn - if field.type == Protocol.CRSF.FLOAT then - displayFn = function(val) - return string.format(field.fmt or "%.0f", val / (field.prec or 1)) - end - else - displayFn = function(val) - return tostring(val) .. (field.unit or "") - end - end - - pg:build({ - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - thickness = 0, - children = { - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + LABEL_PCT, - thickness = 0, - children = { - { - type = "label", - color = COLOR_THEME_PRIMARY1, - text = field.name or "", - }, - }, - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + CTRL_PCT, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - thickness = 0, - children = { - { - type = "numberEdit", - min = field.min or 0, - max = field.max or 255, - get = function() return field.value or 0 end, - set = function(val) - field.value = val - end, - edited = function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadParentFolder(field) - end, - display = displayFn, - active = function() return not field.disabled end, - }, - }, - }, - }, - }, - }) -end - -function UI.createInfoRow(pg, field) - pg:build({ - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - thickness = 0, - children = { - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + LABEL_PCT, - thickness = 0, - children = { - { - type = "label", - color = COLOR_THEME_PRIMARY1, - text = field.name or "", - }, - }, - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + CTRL_PCT, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - thickness = 0, - children = { - { type = "label", text = field.value or "" }, - }, - }, - }, - }, - }) -end - -function UI.createFolderWidget(pg, field, width) - pg:button({ - text = field.name or "", - w = width or (lvgl.PERCENT_SIZE + 100), - h = lvgl.UI_ELEMENT_HEIGHT * 2, - press = function() - App.openFolder(field.id, field.name) - end - }) -end - -function UI.createCommandWidget(pg, field) - pg:button({ - text = field.name or "", - w = lvgl.PERCENT_SIZE + 100, - press = function() - Protocol.handleCommandSave(field) - end - }) -end - -function UI.buildFieldWidget(pg, field, folderWidth) - if not field or not field.name then - return - end - - local fieldType = field.type - - if fieldType == Protocol.CRSF.FOLDER then - return UI.createFolderWidget(pg, field, folderWidth) - end - - if fieldType == Protocol.CRSF.COMMAND then - return UI.createCommandWidget(pg, field) - end - - if fieldType <= Protocol.CRSF.INT16 or fieldType == Protocol.CRSF.FLOAT then - return UI.createNumberRow(pg, field) - end - - if fieldType == Protocol.CRSF.TEXT_SELECTION then - return UI.createChoiceRow(pg, field) - end - - if fieldType == Protocol.CRSF.STRING or fieldType == Protocol.CRSF.INFO then - return UI.createInfoRow(pg, field) - end -end - --- ============================================================================ --- UI: Main build function --- ============================================================================ - -function UI.build() - lvgl.clear() - - local pageOptions = { - title = "ExpressLRS", - subtitle = UI.getSubtitle - } - - if not Navigation.isAtRoot() then - pageOptions.backButton = true - pageOptions.back = function() - App.handleBack() - end - else - pageOptions.back = App.handleBack - end - - UI.currentPage = lvgl.page(pageOptions) - - local outerContainer = UI.currentPage:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - align = CENTER - }) - - local fieldContainer = outerContainer:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_TINY - }) - - local currentFolder = Navigation.getCurrent() - - -- Top spacer for visual breathing room (mirrors bottom spacer) - -- fieldContainer:rectangle({ w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_TINY, thickness = 0 }) - - if currentFolder == Navigation.FOLDER_OTHER_DEVICES then - -- Render device list directly from Protocol.devices - for _, device in ipairs(Protocol.devices) do - if device.id ~= Protocol.deviceId then - fieldContainer:button({ - text = device.name or "Unknown", - w = lvgl.PERCENT_SIZE + 100, - press = function() - App.userSwitchDevice(device.id) - end - }) - end - end - else - -- Normal field rendering for root (nil) or a subfolder ID - if currentFolder == nil then - -- Device name row at root level - UI.createInfoRow(fieldContainer, { name = "Device", value = Protocol.deviceName or "Searching..." }) - end - - local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) - local FOLDERS_PER_ROW = 2 - if IS_NARROW then - FOLDERS_PER_ROW = 1 -- FlySky EL18 portrait, PA01 (LCD_W < 400) - elseif LCD_W >= 800 then - FOLDERS_PER_ROW = 3 -- TX16S MK3 and other HD screens - end - local folderWidth = math.floor(100 / FOLDERS_PER_ROW) - 1 - local i = 1 - while i <= #fieldsInFolder do - local field = fieldsInFolder[i] - - if field.type == Protocol.CRSF.FOLDER then - -- Batch consecutive folders into rows - local folderBatch = {} - while i <= #fieldsInFolder and fieldsInFolder[i].type == Protocol.CRSF.FOLDER do - table.insert(folderBatch, fieldsInFolder[i]) - i = i + 1 - end - - if FOLDERS_PER_ROW == 1 then - -- Single column: add each folder directly, no row wrapper needed - for j = 1, #folderBatch do - UI.createFolderWidget(fieldContainer, folderBatch[j]) - end - else - for j = 1, #folderBatch, FOLDERS_PER_ROW do - local rowContainer = fieldContainer:box({ - w = lvgl.PERCENT_SIZE + 100, - borderPad = 0, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_SMALL, - align = CENTER, - color = COLOR_THEME_PRIMARY2 - }) - - for k = 0, FOLDERS_PER_ROW - 1 do - local folderField = folderBatch[j + k] - if folderField then - UI.createFolderWidget(rowContainer, folderField, lvgl.PERCENT_SIZE + folderWidth) - end - end - end - end - else - UI.buildFieldWidget(fieldContainer, field) - i = i + 1 - end - end - - -- Synthetic Lua script version row at root when device is TX - if currentFolder == nil and Protocol.deviceIsELRS_TX then - UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) - end - - -- Append "Other Devices" button at the end when at root with multiple devices - if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then - fieldContainer:button({ - text = "Other Devices", - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.UI_ELEMENT_HEIGHT * 2, - press = function() - App.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") - end - }) - end - end - - fieldContainer:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_SMALL, - thickness = 0 - }) - - UI.uiBuilt = true -end - --- ============================================================================ --- Dialogs Module: Generic LVGL wrappers (no business logic) --- ============================================================================ - -Dialogs = {} - -function Dialogs.showConfirm(options) - return lvgl.confirm({ - title = options.title, - message = options.message, - confirm = options.onConfirm, - cancel = options.onCancel, - }) -end - -function Dialogs.showMessage(options) - return lvgl.message({ - title = options.title, - message = options.message, - }) -end - --- ============================================================================ --- ModelMismatchDialog: Domain-specific dialog --- ============================================================================ - -local ModelMismatchDialog = {} - -function ModelMismatchDialog.show(onContinue, onExit) - local dg = lvgl.dialog({ - title = "Model Mismatch", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL - }) - - dg:build({ - { - type = "box", - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = "label", text = "Receiver connected but Model ID doesn't match." }, - { type = "label", text = "This prevents controlling the wrong model." }, - { type = "label", text = "To use this receiver:" }, - { type = "label", text = "Set Model Match to OFF" }, - }, - }, - { - type = "box", - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_SMALL, - children = { - { - type = "button", - w = lvgl.PERCENT_SIZE + 48, - text = "Continue", - press = function() - dg:close() - onContinue() - end, - }, - { - type = "button", - w = lvgl.PERCENT_SIZE + 48, - text = "Exit to Change Model", - press = function() - dg:close() - onExit() - end, - }, - }, - }, - }) - - return dg -end - --- ============================================================================ --- NoModuleDialog: Domain-specific dialog --- ============================================================================ - -local NoModuleDialog = {} - -function NoModuleDialog.show(onExit) - lvgl.clear() - - local dg = lvgl.dialog({ - title = "No Module Found: Check Model Settings", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = onExit - }) - - dg:build({ - { - type = "box", - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = "label", text = "- Internal/External module enabled" }, - { type = "label", text = "- Protocol set to CRSF" }, - { type = "label", text = "- Minimum Baud rate (depends on packet rate):" }, - { type = "label", font = SMLSIZE, text = " 400k for 250Hz" }, - { type = "label", font = SMLSIZE, text = " 921k for 500Hz" }, - { type = "label", font = SMLSIZE, text = " 1.87M for F1000" }, - }, - }, - { - type = "box", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - flexFlow = lvgl.FLOW_ROW, - children = { - { - type = "button", - w = lvgl.PERCENT_SIZE + 98, - text = "Exit", - press = function() - dg:close() - onExit() - end, - }, - }, - }, - }) - - return dg -end - --- ============================================================================ --- CommandPage: Non-modal pages for command confirm/executing states. --- Uses lvgl.page() instead of lvgl.dialog() so that run() keeps being --- called and protocol polling continues during command execution. --- ============================================================================ - -local CommandPage = {} -local spinnerAngle = 0 - -local function createSpinner(parent) - local r = 20 - local d = r * 2 - local wrapper = parent:box({ - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - color = COLOR_THEME_PRIMARY2, - w = lvgl.PERCENT_SIZE + 100, - align = CENTER - }) - wrapper:arc({ - radius = r, - thickness = 4, - rounded = true, - color = COLOR_THEME_PRIMARY1, - startAngle = function() - spinnerAngle = (spinnerAngle + 8) % 360 - return spinnerAngle - end, - endAngle = function() - return spinnerAngle + 120 - end, - }) -end - -function CommandPage.showConfirm(name, info, onConfirm, onCancel) - lvgl.clear() - local pg = lvgl.page({ - title = "ExpressLRS", - subtitle = "Send command", - back = onCancel, - }) - - local container = pg:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, - align = CENTER, - }) - - container:build({ - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_LARGE, - thickness = 0, - }, - { - type = "label", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - font = BOLD, - text = name or "Command", - }, - { - type = "label", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - color = COLOR_THEME_DISABLED, - text = info or "", - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_LARGE, - thickness = 0, - }, - { - type = "box", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_SMALL, - children = { - { - type = "button", - w = lvgl.PERCENT_SIZE + 49, - text = "Confirm", - press = onConfirm, - }, - { - type = "button", - w = lvgl.PERCENT_SIZE + 49, - text = "Cancel", - press = onCancel, - }, - }, - }, - }) - - return pg -end - -function CommandPage.showExecuting(title, onCancel) - lvgl.clear() - local pg = lvgl.page({ - title = "ExpressLRS", - subtitle = title or "Executing...", - back = onCancel, - }) - - local container = pg:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, - align = CENTER, - }) - - createSpinner(container) - - container:build({ - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_SMALL, - thickness = 0, - }, - { - type = "label", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - color = COLOR_THEME_DISABLED, - text = "Hold [RTN] to exit and keep running", - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_LARGE, - thickness = 0, - }, - { - type = "button", - w = lvgl.PERCENT_SIZE + 100, - text = "Cancel command", - press = onCancel, - }, - }) - - return pg -end - --- ============================================================================ --- Mock data for simulator --- ============================================================================ - -local function setMock() - local _, rv = getVersion() - if string.sub(rv, -5) ~= "-simu" then - return - end - local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") - if mockModule == nil then - return - end - local mock = mockModule() - Protocol.pop = mock.pop - Protocol.push = mock.push - Protocol.hasCrsfModule = function() return mock.moduleFound end -end - --- ============================================================================ --- Coordination: Command popup handling (in run loop) --- ============================================================================ - -local function onCommandCancel() - Protocol.commandCancel() - App.commandDialog = nil - UI.invalidate() -end - -local function handleCommandPopup() - if not Protocol.fieldPopup then - -- Command finished: rebuild normal UI if we were showing a command page - if App.commandDialog then - App.commandDialog = nil - UI.invalidate() - end - return - end - - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then - Protocol.reloadAllFields() - Protocol.fieldPopup = nil - App.commandDialog = nil - UI.invalidate() - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then - if not App.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then - App.commandDialog = CommandPage.showConfirm( - Protocol.fieldPopup.name, - Protocol.fieldPopup.info, - function() Protocol.commandConfirm() end, - onCommandCancel - ) - end - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then - if not App.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then - App.commandDialog = CommandPage.showExecuting( - Protocol.fieldPopup.name or Protocol.fieldPopup.info, - onCommandCancel - ) - end - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - end -end - --- ============================================================================ --- Coordination: Warning handling (in run loop) --- ============================================================================ - -local function handleWarning() - if App.shouldExit then - return - end - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then - if not App.warningDialog and not App.warningDismissed then - if Protocol.elrsFlagsInfo == "Model Mismatch" then - App.warningDialog = ModelMismatchDialog.show( - function() - App.warningDismissed = true - App.warningDismissedAt = getTime() - UI.invalidate() - end, - function() - App.warningDismissed = true - App.shouldExit = true - end - ) - else - Dialogs.showMessage({ - title = "Warning", - message = Protocol.elrsFlagsInfo - }) - App.warningDialog = true - App.warningDismissed = true - App.warningDismissedAt = getTime() - end - end - -- Re-show after 60 seconds if warning is still active - if App.warningDismissed and App.warningDismissedAt then - if getTime() - App.warningDismissedAt > 6000 then - App.warningDismissed = false - App.warningDismissedAt = nil - App.warningDialog = nil - end - end - else - App.warningDialog = nil - -- Only fully reset if the 60s snooze has expired or was never set - if not App.warningDismissedAt or (getTime() - App.warningDismissedAt > 6000) then - App.warningDismissed = false - App.warningDismissedAt = nil - end - end -end - --- ============================================================================ --- EdgeTX version check --- ============================================================================ - -local versionCheckResult = nil - --- Check EdgeTX version: requires 2.11.5+, 2.12-rc4+, or 3.0+ --- Called once from init(); result stored in versionCheckResult. --- --- Version logic truth table: --- 2.11.4 -> FAIL --- 2.11.5 -> PASS --- 2.11.6 -> PASS --- 2.12.0-rc3 -> FAIL --- 2.12.0-rc4 -> PASS --- 2.12.0 -> PASS (release) --- 2.12.1 -> PASS --- 3.0.0 -> PASS -local function checkEdgeTxVersion() - local ver, radio, maj, minor, rev = getVersion() - - if maj >= 3 then - return true - elseif maj == 2 and minor >= 13 then - return true - elseif maj == 2 and minor == 12 then - local rc = string.match(ver, "%-rc(%d+)") - if rc then - return tonumber(rc) >= 4 - end - return true -- release or dev build - elseif maj == 2 and minor == 11 and rev >= 5 then - return true - end - - return false -end - -local function showVersionRequired() - lvgl.clear() - - local dg = lvgl.dialog({ - title = "EdgeTX Version Not Supported", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = function() App.shouldExit = true end - }) - - dg:build({ - {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ - {type="label", text="Requires EdgeTX:"}, - {type="label", text="- 2.11.5 or later"}, - {type="label", text="- 2.12-rc4 or later"}, - {type="label", text="- 3.0 or later"}, - }}, - {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}, - }}, - }) -end - --- ============================================================================ --- Init --- ============================================================================ - -local function init() - if lvgl == nil then - return - end - - if VERSION_CHECK_ENABLED then - versionCheckResult = checkEdgeTxVersion() - end - - setMock() -end - --- ============================================================================ --- LVGL support check --- ============================================================================ - -local function showLvglRequired() - lcd.clear() - lcd.drawText(5, 10, "LVGL support required", BOLD) - lcd.drawText(5, 20, "Color LCD radio with", 0) - lcd.drawText(5, 30, "EdgeTX 2.11.5+, 2.12-rc4+,", 0) - lcd.drawText(5, 40, "or 3.0+ needed", 0) -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 EdgeTX version (result computed once in init) - if versionCheckResult == false then - if not UI.uiBuilt then - showVersionRequired() - UI.uiBuilt = true - end - if App.shouldExit then - return 2 - end - return 0 - end - - -- Check for CRSF module - if not App.checkCrsfModule() then - if not UI.uiBuilt then - NoModuleDialog.show(function() App.shouldExit = true end) - UI.uiBuilt = true - end - if App.shouldExit then - return 2 - end - return 0 - end - - -- CRSF polling - local targetDevice, anyNewDevice = Protocol.poll() - Protocol.tick() - - -- Check for ELRS 1.x firmware (unsupported) - if Protocol.elrsV1Detected then - if not UI.uiBuilt then - Dialogs.showMessage({ - title = "Unsupported Firmware", - message = "ELRS 1.x firmware detected. Please update to 3.x.", - }) - UI.uiBuilt = true - end - return 0 - end - - -- Activate the target device if it announced/re-announced this cycle - if targetDevice then - App.loadDevice(targetDevice) - end - -- Refresh UI if a new device appeared (shows "Other Devices" button at root, - -- or updates the device list if already viewing that folder). - if anyNewDevice and (Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES or UI.folderWasReady) then - UI.invalidate() - end - - -- Handle command popups - handleCommandPopup() - - -- When a command page is active, skip normal UI management - if not App.commandDialog then - -- Handle warnings - handleWarning() - - -- Rebuild UI once when the current folder's fields become fully loaded - local currentFolder = Navigation.getCurrent() - local folderReady = Protocol.isFolderLoaded(currentFolder) - if folderReady and not UI.folderWasReady then - -- Reclaim memory from temporary tables created during field parsing. - -- Done once per folder load rather than per-field to avoid repeated - -- full GC cycles on the hot path (fieldGetStrOrOpts). - collectgarbage("collect") - if UI.uiBuilt then - UI.invalidate() - end - -- Start background preloading of subfolder contents once root is ready - if currentFolder == nil and not Protocol.backgroundLoading then - Protocol.startBackgroundLoad() - end - end - UI.folderWasReady = folderReady - - -- Build/rebuild UI when needed - if not UI.uiBuilt and #Protocol.fields > 0 then - UI.build() - end - end - - if App.shouldExit then - return 2 - end - - return 0 -end - --- ============================================================================ --- Return --- ============================================================================ - -return { init = init, run = run, useLvgl = true } diff --git a/color/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua similarity index 100% rename from color/SCRIPTS/ELRS/crsf.lua rename to src/SCRIPTS/ELRS/crsf.lua diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua new file mode 100644 index 0000000..4d8ca80 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -0,0 +1,205 @@ +-- TNS|ExpressLRS1|TNE +---- ######################################################################### +---- # # +---- # Copyright (C) OpenTX, adapted for ExpressLRS # +---- # # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- # # +---- # Unified tool for BW and color LCD radios (EdgeTX 2.11+) # +---- ######################################################################### + +local VERSION = "r2" +local useLvgl = (lvgl ~= nil) + +-- ============================================================================ +-- Load shared modules +-- ============================================================================ + +local shim = loadScript("/SCRIPTS/TOOLS/ExpressLRS/shim.lua")() +local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(shim) +local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() + +-- ============================================================================ +-- App Module: Pure business logic (zero UI references) +-- ============================================================================ + +local App = { + crsfModuleChecked = false, + crsfModuleFound = false, + shouldExit = false, +} + +function App.reset() + App.crsfModuleChecked = false + App.crsfModuleFound = false + App.shouldExit = false + Protocol.reset() +end + +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + App.crsfModuleChecked = true + App.crsfModuleFound = Protocol.hasCrsfModule() + return App.crsfModuleFound +end + +-- Returns true if device was set, false if no change needed. +function App.loadDevice(device) + if Protocol.setDevice(device) then + Navigation.reset() + return true + end + return false +end + +-- Returns true if device was switched. +function App.switchDevice(deviceId, viewState) + local device = Protocol.getDevice(deviceId) + if not device then + return false + end + local prevDeviceId = Protocol.deviceId + if Protocol.setDevice(device) then + Navigation.openDevice(device.name, prevDeviceId, viewState) + return true + end + return false +end + +-- Navigate into folder. +function App.enterFolder(folderId, folderName, viewState) + Navigation.openFolder(folderId, folderName, viewState) + Protocol.loadFolderChildren(folderId) +end + +-- Returns navigation entry (or nil). +function App.goBack() + return Navigation.goBack() +end + +-- Reload at root: switch back to TX device or reload fields + ping. +function App.reloadAtRoot() + if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then + local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + if txDevice then + App.loadDevice(txDevice) + end + else + Protocol.allocateFields() + Protocol.reloadAllFields() + end + Protocol.pingDevices() +end + +-- ============================================================================ +-- Mock data for simulator +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + Protocol.pop = mock.pop + Protocol.push = mock.push + Protocol.hasCrsfModule = function() + return mock.moduleFound + end +end + +-- ============================================================================ +-- UI loading (deferred to init) +-- ============================================================================ + +local UI + +local function init() + local deps = { + App = App, + Navigation = Navigation, + Protocol = Protocol, + shim = shim, + VERSION = VERSION, + } + if useLvgl then + UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua")(deps) + else + UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua")(deps) + end + UI.init() + setMock() +end + +-- ============================================================================ +-- Run (shared orchestrator) +-- ============================================================================ + +local function run(event, touchState) + if event == nil then + return 2 + end + + -- UI-specific pre-checks (LVGL: version/availability check; BW: not defined) + if UI.preCheck then + local result = UI.preCheck() + if result ~= nil then + return result + end + end + + if not App.checkCrsfModule() then + UI.handleNoModule() + if App.shouldExit then + return 2 + end + return 0 + end + + local targetDevice, anyNewDevice = Protocol.poll() + Protocol.tick() + + if Protocol.elrsV1Detected then + UI.handleUnsupported() + return 0 + end + + if targetDevice then + if App.loadDevice(targetDevice) then + UI.onDeviceLoaded() + end + end + if anyNewDevice then + UI.onNewDevice() + end + + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + collectgarbage("collect") + UI.invalidate() + if currentFolder == nil and not Protocol.backgroundLoading then + Protocol.startBackgroundLoad() + end + end + UI.folderWasReady = folderReady + + UI.render(event, touchState) + + if App.shouldExit then + return 2 + end + return 0 +end + +-- ============================================================================ +-- Return +-- ============================================================================ + +return { init = init, run = run, useLvgl = useLvgl } diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua new file mode 100644 index 0000000..77690d4 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -0,0 +1,82 @@ +---- ######################################################################### +---- # Navigation Module: Folder navigation stack and methods # +---- # Zero dependencies on other modules # +---- ######################################################################### + +local Navigation = { + stack = {}, + -- Navigation entry type constants (integers to save RAM vs strings) + TYPE_FOLDER = 0, + TYPE_DEVICE = 1, + -- Synthetic folder IDs + FOLDER_OTHER_DEVICES = -1, +} + +function Navigation.getCurrent() + local top = Navigation.stack[#Navigation.stack] + return top and top.id or nil -- nil if at root (or device root) +end + +function Navigation.isAtRoot() + return #Navigation.stack == 0 +end + +-- Check if the user has navigated into a device (for hiding "Other Devices") +function Navigation.hasDeviceEntry() + for _, entry in ipairs(Navigation.stack) do + if entry.type == Navigation.TYPE_DEVICE then + return true + end + end + return false +end + +-- viewState: optional table of UI state to preserve (e.g. cursor position). +-- Merged into the nav entry so the UI can restore it on goBack(). +function Navigation.openFolder(folderId, folderName, viewState) + local baseName = folderName + if folderName then + baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName + end + local entry = { + type = Navigation.TYPE_FOLDER, + id = folderId, + name = baseName, + } + if viewState then + for k, v in pairs(viewState) do + entry[k] = v + end + end + Navigation.stack[#Navigation.stack + 1] = entry +end + +function Navigation.openDevice(deviceName, prevDeviceId, viewState) + local entry = { + type = Navigation.TYPE_DEVICE, + id = nil, + name = deviceName, + prevDeviceId = prevDeviceId, + } + if viewState then + for k, v in pairs(viewState) do + entry[k] = v + end + end + Navigation.stack[#Navigation.stack + 1] = entry +end + +function Navigation.goBack() + if #Navigation.stack > 0 then + local entry = Navigation.stack[#Navigation.stack] + Navigation.stack[#Navigation.stack] = nil + return entry + end + return nil +end + +function Navigation.reset() + Navigation.stack = {} +end + +return Navigation diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua new file mode 100644 index 0000000..8d7d3d5 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -0,0 +1,737 @@ +---- ######################################################################### +---- # Protocol Module: CRSF constants, parsing, and field operations # +---- # Shared between BW and LVGL UI implementations # +---- ######################################################################### + +local shim = ... + +local Protocol = { + -- EdgeTX module type for CRSF/ELRS + MODULE_TYPE_CROSSFIRE = 5, + + -- CRSF Field Type Constants + CRSF = { + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + UINT32 = 4, + INT32 = 5, + UINT64 = 6, + INT64 = 7, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + -- Internal/extended types (not in official CRSF protocol) + BACK_EXIT = 14, + DEVICE = 15, + DEVICE_FOLDER = 16, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- ELRS flags: bits 0-1 are status (connected, status1), + -- bits 2-4 are warnings (model match, armed, warning1), + -- bits 5-7 are critical errors (error connected, error baudrate, critical2) + ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only + ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags + + -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, + }, + + -- Handlers dispatch table (populated after function definitions) + handlers = {}, + + -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua + deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) + handsetId = 0xEF, -- ADDRESS_ELRS_LUA + deviceName = nil, + deviceIsELRS_TX = nil, + + -- Fields collection + fields = {}, + fieldsCount = 0, + fieldPopup = nil, + + -- Devices collection + devices = {}, + + -- Status/flags (parsed from ELRS info messages) + elrsFlags = 0, + elrsFlagsInfo = "", + elrsV1Detected = false, + receivedPackets = nil, + lostPackets = nil, + + -- Protocol timing + linkstatTimeout = 100, + pingTimeout = 0, + + -- Communication state + fieldTimeout = 0, + fieldChunk = 0, + fieldData = nil, + loadQueue = {}, + expectChunksRemain = -1, + backgroundLoading = false, + + -- Connection transition tracking (for auto-discovery on reconnect) + wasConnected = false, +} + +-- ============================================================================ +-- Reset +-- ============================================================================ + +function Protocol.reset() + Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER + Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA + Protocol.deviceName = nil + Protocol.deviceIsELRS_TX = nil + + Protocol.fields = {} + Protocol.fieldsCount = 0 + Protocol.fieldPopup = nil + + Protocol.devices = {} + + Protocol.elrsFlags = 0 + Protocol.elrsFlagsInfo = "" + Protocol.elrsV1Detected = false + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + + Protocol.linkstatTimeout = 100 + Protocol.pingTimeout = 0 + + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + Protocol.expectChunksRemain = -1 + Protocol.backgroundLoading = false + Protocol.wasConnected = false +end + +-- ============================================================================ +-- Telemetry wrappers (replaced by setMock in simulator) +-- ============================================================================ + +function Protocol.pop() + return crossfireTelemetryPop() +end + +function Protocol.push(command, data) + return crossfireTelemetryPush(command, data) +end + +function Protocol.pingDevices() + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) +end + +-- Check connection state from elrsFlags +function Protocol.isConnected() + return bit32.btest(Protocol.elrsFlags, 1) +end + +-- Response timeout for PARAMETER_READ: +-- 0.5s for local TX module, 5s for remote devices relayed over air link. +function Protocol.fieldResponseTimeout() + return Protocol.deviceIsELRS_TX and 50 or 500 +end + +-- Check if a CRSF-compatible module is available +function Protocol.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +-- Set active device and prepare fields +-- Returns true if device changed, false if no change needed +function Protocol.setDevice(device) + if not device then + return false + end + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then + return false + end + + Protocol.deviceId = device.id + Protocol.elrsFlags = 0 + Protocol.deviceName = device.name + Protocol.fieldsCount = device.fieldCount + Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + + Protocol.allocateFields() + Protocol.reloadAllFields() + return true +end + +-- ============================================================================ +-- Field management functions +-- ============================================================================ + +function Protocol.allocateFields() + Protocol.fields = {} + Protocol.fields[0] = {} -- root folder (field 0) + for i = 1, Protocol.fieldsCount do + Protocol.fields[i] = {} + end +end + +-- Check if all children of a folder have been loaded (have names). +-- folderId: the folder's field ID, or nil for root (uses field 0). +function Protocol.isFolderLoaded(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return false + end + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if not child or not child.name or child.nameStale then + return false + end + end + return true +end + +-- Return load progress for a folder's children as (loaded, total). +-- Returns nil if the folder or its children list is unknown yet. +function Protocol.getFolderLoadProgress(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return nil + end + local total = #folder.children + local loaded = 0 + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name then + loaded = loaded + 1 + end + end + return loaded, total +end + +function Protocol.reloadAllFields() + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + -- Start by loading only field 0 (root folder). + -- Its response contains child IDs; only root children are auto-queued. + -- Subfolder children are loaded on-demand via loadFolderChildren(). + Protocol.loadQueue[1] = 0 +end + +-- Parameterized: takes folderId instead of accessing Navigation +function Protocol.getFieldsInFolder(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return {} + end + local result = {} + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name and not child.hidden then + result[#result + 1] = child + end + end + return result +end + +function Protocol.getDevice(id) + for _, device in ipairs(Protocol.devices) do + if device.id == id then + return device + end + end +end + +function Protocol.reloadCurField(field) + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id +end + +-- Queue unloaded children of a folder for on-demand loading. +function Protocol.loadFolderChildren(folderId) + local folder = Protocol.fields[folderId] + if not folder or not folder.children then + return + end + for i = #folder.children, 1, -1 do + local childId = folder.children[i] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- Queue all unloaded subfolder children for background preloading. +function Protocol.startBackgroundLoad() + Protocol.backgroundLoading = true + for i = 1, #Protocol.fields do + local field = Protocol.fields[i] + if field.type == Protocol.CRSF.FOLDER and field.children then + for j = #field.children, 1, -1 do + local childId = field.children[j] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- ============================================================================ +-- Field data helpers +-- ============================================================================ + +function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) + local r = last or (isOpts and {}) + local optParts = {} + local vcnt = 0 + repeat + local b = data[offset] + offset = offset + 1 + + if not last then + if r and (b == 59 or b == 0) then + r[#r + 1] = shim.tableConcat(optParts) + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + elseif b ~= 0 then + -- Translate legacy OpenTX arrow bytes (0xC0/0xC1) from ELRS firmware + -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs + optParts[#optParts + 1] = ({ + [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), + [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) + })[b] or string.char(b) + end + end + until b == 0 + + return (r or shim.tableConcat(optParts)), offset, vcnt +end + +function Protocol.fieldGetValue(data, offset, size) + local result = 0 + for i = 0, size - 1 do + result = bit32.lshift(result, 8) + data[offset + i] + end + return result +end + +-- ============================================================================ +-- Field load functions +-- ============================================================================ + +local function fieldUnsignedLoad(field, data, offset, size, unitoffset) + field.value = Protocol.fieldGetValue(data, offset, size) + field.min = Protocol.fieldGetValue(data, offset + size, size) + field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) + field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + if size ~= 1 then + field.size = size + end +end + +local function fieldUnsignedToSigned(field, size) + local bandval = bit32.lshift(0x80, (size - 1) * 8) + field.value = field.value - bit32.band(field.value, bandval) * 2 + field.min = field.min - bit32.band(field.min, bandval) * 2 + field.max = field.max - bit32.band(field.max, bandval) * 2 +end + +local function fieldSignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedToSigned(field, size) + field.size = -size +end + +function Protocol.fieldIntLoad(field, data, offset) + local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad + return loadFn(field, data, offset, math.floor(field.type / 2) + 1) +end + +function Protocol.fieldFloatLoad(field, data, offset) + fieldSignedLoad(field, data, offset, 4, 21) + field.prec = data[offset + 16] + if field.prec > 3 then + field.prec = 3 + end + field.step = Protocol.fieldGetValue(data, offset + 17, 4) + field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit + field.prec = 10 ^ field.prec +end + +function Protocol.fieldTextSelLoad(field, data, offset) + local vcnt + local cached = field.dirty == nil and field.values + field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) + if not cached then + field.disabled = vcnt <= 1 + end + field.value = data[offset] + field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + field.dirty = nil +end + +function Protocol.fieldStringLoad(field, data, offset) + field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) + if #data >= offset then + field.maxlen = data[offset] + end +end + +function Protocol.fieldCommandLoad(field, data, offset) + field.status = data[offset] + field.timeout = data[offset + 1] + field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) + if field.status == Protocol.CRSF.CMD_IDLE then + Protocol.fieldPopup = nil + end +end + +function Protocol.fieldFolderLoad(field, data, offset) + field.children = {} + while data[offset] and data[offset] ~= 0xFF do + field.children[#field.children + 1] = data[offset] + offset = offset + 1 + end +end + +-- ============================================================================ +-- Field save functions +-- ============================================================================ + +function Protocol.fieldIntSave(field) + local value = field.value + local size = field.size or 1 + if size < 0 then + size = -size + if value < 0 then + value = bit32.lshift(0x100, (size - 1) * 8) + value + end + end + + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + for i = size - 1, 0, -1 do + frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 + end + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + +-- ============================================================================ +-- Related fields reload (for value changes) +-- ============================================================================ + +function Protocol.reloadParentFolder(field) + if field.parent and Protocol.fields[field.parent] then + Protocol.fields[field.parent].nameStale = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent + local minTimeout = getTime() + Protocol.fieldResponseTimeout() + if Protocol.fieldTimeout < minTimeout then + Protocol.fieldTimeout = minTimeout + end + end +end + +function Protocol.reloadRelatedFields(field) + Protocol.reloadParentFolder(field) + + for fieldId = Protocol.fieldsCount, 1, -1 do + local sibling = Protocol.fields[fieldId] + local siblingType = sibling.type or 99 + if fieldId ~= field.id + and sibling.parent == field.parent + and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then + sibling.dirty = true + sibling.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId + end + end + + field.dirty = true + field.name = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id + Protocol.fieldTimeout = getTime() + 20 + Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 +end + +function Protocol.handleCommandSave(field) + Protocol.reloadCurField(field) + + if field.status ~= nil then + if field.status < Protocol.CRSF.CMD_CONFIRMED then + field.status = Protocol.CRSF.CMD_CLICK + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) + Protocol.fieldPopup = field + Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE + Protocol.fieldTimeout = getTime() + field.timeout + end + end +end + +function Protocol.commandConfirm() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED + end +end + +function Protocol.commandCancel() + if Protocol.fieldPopup then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.fieldPopup = nil + end +end + +-- ============================================================================ +-- Handlers dispatch table +-- ============================================================================ + +Protocol.handlers = { + [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT32 + 1] = nil, + [Protocol.CRSF.INT32 + 1] = nil, + [Protocol.CRSF.UINT64 + 1] = nil, + [Protocol.CRSF.INT64 + 1] = nil, + [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, +} + +-- ============================================================================ +-- CRSF message parsing +-- ============================================================================ + +function Protocol.parseDeviceInfoMessage(data) + local id = data[2] + local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) + local device = Protocol.getDevice(id) + local isNew = (device == nil) + if isNew then + device = { id = id } + Protocol.devices[#Protocol.devices + 1] = device + end + device.name = newName + device.fieldCount = data[offset + 12] + device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID + return device, isNew +end + +function Protocol.parseParameterInfoMessage(data) + local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return false + end + local field = Protocol.fields[fieldId] + local chunksRemain = data[4] + if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then + return false + end + + local offset + if chunksRemain > 0 or Protocol.fieldChunk > 0 then + Protocol.fieldData = Protocol.fieldData or {} + for i = 5, #data do + Protocol.fieldData[#Protocol.fieldData + 1] = data[i] + data[i] = nil + end + offset = 1 + else + Protocol.fieldData = data + offset = 5 + end + + if chunksRemain > 0 then + Protocol.fieldChunk = Protocol.fieldChunk + 1 + Protocol.expectChunksRemain = chunksRemain - 1 + return false + else + Protocol.loadQueue[#Protocol.loadQueue] = nil + + if #Protocol.fieldData > (offset + 2) then + field.id = fieldId + field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil + field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil + local cachedName = (not field.nameStale) and field.name or nil + field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) + field.nameStale = nil + local handler = Protocol.handlers[field.type + 1] + if handler and handler.load then + handler.load(field, Protocol.fieldData, offset) + end + if field.min == 0 then + field.min = nil + end + if field.max == 0 then + field.max = nil + end + + -- Auto-queue children for root folder (field 0) and during background preloading. + if field.type == Protocol.CRSF.FOLDER and field.children + and (fieldId == 0 or Protocol.backgroundLoading) then + for i = #field.children, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] + end + end + end + + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + + return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 + end +end + +function Protocol.parseElrsInfoMessage(data) + if data[2] ~= Protocol.deviceId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return + end + + Protocol.lostPackets = data[3] + Protocol.receivedPackets = (data[4] * 256) + data[5] + local newFlags = data[6] + Protocol.elrsFlags = newFlags + Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) +end + +function Protocol.parseElrsV1Message(data) + if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then + return + end + Protocol.elrsV1Detected = true +end + +-- ============================================================================ +-- Main CRSF communication loop +-- ============================================================================ + +function Protocol.poll() + local command, data + local targetDevice = nil + local anyNewDevice = false + + repeat + command, data = Protocol.pop() + if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then + local device, isNew = Protocol.parseDeviceInfoMessage(data) + if device.id == Protocol.deviceId then + targetDevice = device + end + if isNew then + anyNewDevice = true + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + Protocol.parseParameterInfoMessage(data) + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + elseif Protocol.fieldPopup then + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then + Protocol.parseElrsV1Message(data) + elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then + Protocol.parseElrsInfoMessage(data) + end + until command == nil + + return targetDevice, anyNewDevice +end + +function Protocol.tick() + -- Ping on connection transition (device may have changed) + local connected = Protocol.isConnected() + if connected and not Protocol.wasConnected then + Protocol.pingDevices() + end + Protocol.wasConnected = connected + + local time = getTime() + -- Periodic ping for initial device discovery + if #Protocol.devices == 0 and time > Protocol.pingTimeout then + Protocol.pingDevices() + Protocol.pingTimeout = time + 100 -- 1s + end + + if Protocol.fieldPopup then + if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) + Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout + end + elseif time > Protocol.linkstatTimeout then + if Protocol.deviceIsELRS_TX then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + else + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + end + Protocol.linkstatTimeout = time + 100 + elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then + if #Protocol.loadQueue > 0 then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) + Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() + else + Protocol.backgroundLoading = false + end + end +end + +return Protocol diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua b/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua new file mode 100644 index 0000000..fcd585b --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua @@ -0,0 +1,27 @@ +---- ######################################################################### +---- # Compat layer for BW radios missing standard Lua library functions # +---- ######################################################################### + +local shim = {} + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua new file mode 100644 index 0000000..36bd2bf --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -0,0 +1,569 @@ +---- ######################################################################### +---- # BW LCD UI: Rendering, input handling, cursor management # +---- # For black & white radios (no LVGL required) # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local Navigation = deps.Navigation +local Protocol = deps.Protocol +local VERSION = deps.VERSION + +-- Popup compatibility wrapper (set in UI.init) +local popupCompat + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + -- Cursor/selection state (owned entirely by this module) + lineIndex = 1, + pageOffset = 0, + edit = nil, + + -- Visible field list (rebuilt on invalidate) + visibleFields = nil, + + -- Layout constants (set in UI.init) + COL1 = 0, + COL2 = 70, + maxLineIndex = 6, + textSize = 8, + textYoffset = 3, + + -- Redraw state + forceRedraw = true, + folderWasReady = false, + + -- Warning flashing + titleShowWarn = nil, + titleShowWarnTimeout = 100, + + -- Command popup spinner + commandRunningIndicator = 1, +} + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + else + UI.COL2 = 70 + end + if LCD_H == 96 then + UI.maxLineIndex = 9 + else + UI.maxLineIndex = 6 + end + UI.COL1 = 0 + UI.textYoffset = 3 + UI.textSize = 8 + + -- Determine popupConfirmation argument count + local _, _, major = getVersion() + if major ~= 1 then + popupCompat = popupConfirmation + else + popupCompat = function(t, m, e) + return popupConfirmation(t, e) + end + end +end + +-- ============================================================================ +-- Interface: invalidate (does NOT reset cursor) +-- ============================================================================ + +function UI.invalidate() + UI.forceRedraw = true + UI.visibleFields = nil +end + +-- ============================================================================ +-- Interface: onDeviceLoaded (resets cursor + invalidates) +-- ============================================================================ + +function UI.onDeviceLoaded() + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() +end + +-- ============================================================================ +-- Interface: onNewDevice +-- ============================================================================ + +function UI.onNewDevice() + UI.invalidate() +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + UI.drawAlert(" No ExpressLRS", { + " Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + " also set Internal RF to", + " CRSF in SYS->Hardware", + }) +end + +-- ============================================================================ +-- Interface: handleUnsupported +-- ============================================================================ + +function UI.handleUnsupported() + UI.drawAlert("Unsupported Firmware", { + "ELRS 1.x firmware detected.", + "Please update to 3.x.", + }) +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, touchState) + -- Warning flashing timer + local time = getTime() + if time > UI.titleShowWarnTimeout then + UI.titleShowWarn = (Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarnTimeout = time + 100 + UI.forceRedraw = true + end + + -- Force redraw during loading to show progress bar + if #Protocol.loadQueue > 0 then + UI.forceRedraw = true + end + + -- Render: command popup or normal page + if Protocol.fieldPopup ~= nil then + UI.drawPopup(event) + elseif event ~= 0 or UI.forceRedraw or UI.edit then + UI.drawPage(event) + UI.forceRedraw = false + end +end + +-- ============================================================================ +-- Alert screen (clear screen + title + body messages) +-- ============================================================================ + +function UI.drawAlert(title, msgs) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (UI.textSize * 2) - 2 + for _, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + UI.textSize + end +end + +-- ============================================================================ +-- User action handlers (call App for business logic, manage own state) +-- ============================================================================ + +function UI.openFolder(folderId, folderName) + App.enterFolder(folderId, folderName, { li = UI.lineIndex, po = UI.pageOffset }) + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() +end + +function UI.switchDevice(deviceId) + if App.switchDevice(deviceId, { li = UI.lineIndex, po = UI.pageOffset }) then + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() + end +end + +function UI.handleBack() + if Navigation.isAtRoot() then + App.reloadAtRoot() + else + local entry = App.goBack() + if entry then + UI.lineIndex = entry.li or 1 + UI.pageOffset = entry.po or 0 + if entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + end + end + UI.invalidate() +end + +-- ============================================================================ +-- Build visible field list for current navigation state +-- ============================================================================ + +function UI.buildVisibleFields() + local currentFolder = Navigation.getCurrent() + local vf = {} + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.CRSF.DEVICE } + end + end + else + local fields = Protocol.getFieldsInFolder(currentFolder) + for _, field in ipairs(fields) do + vf[#vf + 1] = field + end + + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + vf[#vf + 1] = { name = "Other Devices", type = Protocol.CRSF.DEVICE_FOLDER } + end + end + + UI.visibleFields = vf +end + +function UI.getField(line) + if not UI.visibleFields then + UI.buildVisibleFields() + end + return UI.visibleFields[line] +end + +function UI.getFieldCount() + if not UI.visibleFields then + UI.buildVisibleFields() + end + return #UI.visibleFields +end + +function UI.getSelectableCount() + return UI.getFieldCount() + 1 +end + +function UI.isOnBackExit() + return UI.lineIndex > UI.getFieldCount() +end + +function UI.getBackExitLabel() + if Navigation.isAtRoot() then + return "-- EXIT (" .. VERSION .. ") --" + else + return "----BACK----" + end +end + +-- ============================================================================ +-- Field value increment +-- ============================================================================ + +function UI.incrField(step) + local field = UI.getField(UI.lineIndex) + if not field then + return + end + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until (newval == min or newval == max) +end + +-- ============================================================================ +-- Field selection navigation +-- ============================================================================ + +function UI.selectField(step) + local count = UI.getSelectableCount() + if count == 0 then + return + end + local fieldCount = UI.getFieldCount() + local newLineIndex = UI.lineIndex + repeat + newLineIndex = newLineIndex + step + if newLineIndex <= 0 then + newLineIndex = count + elseif newLineIndex > count then + newLineIndex = 1 + UI.pageOffset = 0 + end + if newLineIndex > fieldCount then + break + end + local field = UI.getField(newLineIndex) + if field and field.name then + break + end + until newLineIndex == UI.lineIndex + UI.lineIndex = newLineIndex + if UI.lineIndex > UI.maxLineIndex + UI.pageOffset then + UI.pageOffset = UI.lineIndex - UI.maxLineIndex + elseif UI.lineIndex <= UI.pageOffset then + UI.pageOffset = UI.lineIndex - 1 + end +end + +-- ============================================================================ +-- BW field display functions +-- ============================================================================ + +local function fieldIntDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value .. field.unit, attr) +end + +local function fieldFloatDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec), attr) +end + +local function fieldTextSelDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. field.unit, attr) +end + +local function fieldStringDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value or "", attr) +end + +local function fieldFolderDisplay(field, y, attr) + lcd.drawText(UI.COL1, y, "> " .. field.name, attr + BOLD) +end + +local function fieldCommandDisplay(field, y, attr) + lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) +end + +local displayHandlers = {} +displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay +displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay +displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay +displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay +displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay +displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay + +-- ============================================================================ +-- Title bar drawing +-- ============================================================================ + +function UI.drawTitle() + local barHeight = 9 + local goodBadPkt = "" + if Protocol.receivedPackets then + local state = Protocol.isConnected() and "C" or "-" + goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if not UI.titleShowWarn then + lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) + lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight - 1, SOLID, INVERS) + end + + if loaded and total and total > 0 and loaded < total then + lcd.drawFilledRectangle(UI.COL2, 0, LCD_W, barHeight, GREY_DEFAULT) + lcd.drawGauge(0, 0, UI.COL2, barHeight, loaded, total, 0) + else + lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) + if UI.titleShowWarn then + lcd.drawText(UI.COL1, 1, Protocol.elrsFlagsInfo, INVERS) + else + lcd.drawText(UI.COL1, 1, Protocol.deviceName or "Searching...", INVERS) + end + end +end + +-- ============================================================================ +-- Warning display +-- ============================================================================ + +function UI.drawWarning() + lcd.drawText(UI.COL1, UI.textSize * 2, "Error:") + lcd.drawText(UI.COL1, UI.textSize * 3, Protocol.elrsFlagsInfo) + lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER) +end + +-- ============================================================================ +-- Event handling +-- ============================================================================ + +function UI.handleEvent(event) + if UI.getSelectableCount() == 0 then + return + end + + if event == EVT_VIRTUAL_EXIT then + if UI.edit then + UI.edit = nil + local field = UI.getField(UI.lineIndex) + if field and field.id then + Protocol.reloadCurField(field) + end + else + UI.handleBack() + end + elseif event == EVT_VIRTUAL_ENTER then + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + Protocol.elrsFlags = 0 + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + elseif UI.isOnBackExit() then + if Navigation.isAtRoot() then + App.shouldExit = true + else + UI.handleBack() + end + else + local field = UI.getField(UI.lineIndex) + if field and field.name then + local ft = field.type + + if ft == Protocol.CRSF.FOLDER then + UI.openFolder(field.id, field.name) + elseif ft == Protocol.CRSF.DEVICE_FOLDER then + UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + elseif ft == Protocol.CRSF.DEVICE then + UI.switchDevice(field.id) + elseif ft == Protocol.CRSF.COMMAND then + Protocol.handleCommandSave(field) + elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then + UI.edit = not UI.edit + if not UI.edit then + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end + end + end + end + elseif UI.edit then + if event == EVT_VIRTUAL_NEXT then + UI.incrField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.incrField(-1) + end + else + if event == EVT_VIRTUAL_NEXT then + UI.selectField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.selectField(-1) + end + end +end + +-- ============================================================================ +-- Main page rendering +-- ============================================================================ + +function UI.drawPage(event) + UI.handleEvent(event) + + lcd.clear() + UI.drawTitle() + + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + UI.drawWarning() + else + local totalCount = UI.getSelectableCount() + for y = 1, UI.maxLineIndex + 1 do + local idx = UI.pageOffset + y + if idx > totalCount then + break + end + local yPos = y * UI.textSize + UI.textYoffset + local isSelected = (UI.lineIndex == idx) + local attr = isSelected and ((UI.edit and BLINK or 0) + INVERS) or 0 + + if idx > UI.getFieldCount() then + lcd.drawText(10, yPos, "[" .. UI.getBackExitLabel() .. "]", attr + BOLD) + else + local field = UI.getField(idx) + if field and field.name then + local ft = field.type + if ft < Protocol.CRSF.FOLDER or ft == Protocol.CRSF.INFO then + lcd.drawText(UI.COL1, yPos, field.name, 0) + end + local displayFn = displayHandlers[ft] + if displayFn then + displayFn(field, yPos, attr) + end + end + end + end + end +end + +-- ============================================================================ +-- Command popup rendering +-- ============================================================================ + +function UI.drawPopup(event) + if event == EVT_VIRTUAL_EXIT then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.fieldTimeout = getTime() + 200 + end + + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + popupCompat(Protocol.fieldPopup.info, "Stopped!", event) + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + local result = popupCompat(Protocol.fieldPopup.info, "PRESS [OK] to confirm", event) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "OK" then + Protocol.commandConfirm() + elseif result == "CANCEL" then + Protocol.fieldPopup = nil + end + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if Protocol.fieldChunk == 0 then + UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 + end + local result = popupCompat( + Protocol.fieldPopup.info .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", + "Press [RTN] to exit", + event) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "CANCEL" then + Protocol.commandCancel() + end + end +end + +return UI diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua new file mode 100644 index 0000000..a2a76f2 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -0,0 +1,1066 @@ +---- ######################################################################### +---- # LVGL UI: Color LCD rendering, dialogs, command pages # +---- # For color LCD radios with EdgeTX 2.11.4+ LVGL support # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local Navigation = deps.Navigation +local Protocol = deps.Protocol +local VERSION = deps.VERSION + +local VERSION_CHECK_ENABLED = true + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + currentPage = nil, + uiBuilt = false, + folderWasReady = false, + + -- Warning/command state (LVGL-specific) + warningDismissed = false, + warningDismissedAt = nil, + warningDialog = nil, + commandDialog = nil +} + +-- ============================================================================ +-- Dialogs Module: Generic LVGL wrappers +-- ============================================================================ + +local Dialogs = {} + +function Dialogs.showConfirm(options) + return lvgl.confirm({ + title = options.title, + message = options.message, + confirm = options.onConfirm, + cancel = options.onCancel, + }) +end + +function Dialogs.showMessage(options) + return lvgl.message({ + title = options.title, + message = options.message, + }) +end + +-- ============================================================================ +-- ModelMismatchDialog +-- ============================================================================ + +local ModelMismatchDialog = {} + +function ModelMismatchDialog.show(onContinue, onExit) + local dg = lvgl.dialog({ + title = "Model Mismatch", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL + }) + + dg:build({ + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "Receiver connected but Model ID doesn't match." }, + { type = "label", text = "This prevents controlling the wrong model." }, + { type = "label", text = "To use this receiver:" }, + { type = "label", text = "Set Model Match to OFF" }, + }, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 48, + text = "Continue", + press = function() + dg:close() + onContinue() + end, + }, + { + type = "button", + w = lvgl.PERCENT_SIZE + 48, + text = "Exit to Change Model", + press = function() + dg:close() + onExit() + end, + }, + }, + }, + }) + + return dg +end + +-- ============================================================================ +-- NoModuleDialog +-- ============================================================================ + +local NoModuleDialog = {} + +function NoModuleDialog.show(onExit) + lvgl.clear() + + local dg = lvgl.dialog({ + title = "No Module Found: Check Model Settings", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = onExit + }) + + dg:build({ + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "- Internal/External module enabled" }, + { type = "label", text = "- Protocol set to CRSF" }, + { type = "label", text = "- Minimum Baud rate (depends on packet rate):" }, + { type = "label", font = SMLSIZE, text = " 400k for 250Hz" }, + { type = "label", font = SMLSIZE, text = " 921k for 500Hz" }, + { type = "label", font = SMLSIZE, text = " 1.87M for F1000" }, + }, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 98, + text = "Exit", + press = function() + dg:close() + onExit() + end, + }, + }, + }, + }) + + return dg +end + +-- ============================================================================ +-- CommandPage: Non-modal pages for command confirm/executing states +-- ============================================================================ + +local CommandPage = {} +local spinnerAngle = 0 + +local function createSpinner(parent) + local r = 20 + local wrapper = parent:box({ + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + color = COLOR_THEME_PRIMARY2, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER + }) + wrapper:arc({ + radius = r, + thickness = 4, + rounded = true, + color = COLOR_THEME_PRIMARY1, + startAngle = function() + spinnerAngle = (spinnerAngle + 8) % 360 + return spinnerAngle + end, + endAngle = function() + return spinnerAngle + 120 + end, + }) +end + +function CommandPage.showConfirm(name, info, onConfirm, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = "Send command", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + borderPad = { left = lvgl.PAD_TINY, right = lvgl.PAD_TINY }, + }) + + container:build({ + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + font = BOLD, + text = name or "Command", + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = info or "", + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_OUTLINE, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 49, + text = "Confirm", + press = onConfirm, + }, + { + type = "button", + w = lvgl.PERCENT_SIZE + 49, + text = "Cancel", + press = onCancel, + }, + }, + }, + }) + + return pg +end + +function CommandPage.showExecuting(title, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = title or "Executing...", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + borderPad = { left = lvgl.PAD_TINY, right = lvgl.PAD_TINY }, + }) + + container:build({ + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + }) + createSpinner(container) + container:build({ + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "label", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = "Hold [RTN] to exit and keep running", + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = "box", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_OUTLINE, + children = { + { + type = "button", + w = lvgl.PERCENT_SIZE + 100, + text = "Cancel command", + press = onCancel, + }, + }, + }, + }) + + return pg +end + +-- ============================================================================ +-- EdgeTX version check +-- ============================================================================ + +local versionCheckResult = nil + +local function checkEdgeTxVersion() + local ver, radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor >= 13 then + return true + elseif maj == 2 and minor == 12 then + local rc = string.match(ver, "%-rc(%d+)") + if rc then + return tonumber(rc) >= 4 + end + return true + elseif maj == 2 and minor == 11 and rev >= 5 then + return true + end + + return false +end + +local function showVersionRequired() + lvgl.clear() + + local dg = lvgl.dialog({ + title = "EdgeTX Version Not Supported", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() App.shouldExit = true end + }) + + dg:build({ + {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ + {type="label", text="Requires EdgeTX:"}, + {type="label", text="- 2.11.5 or later"}, + {type="label", text="- 2.12-rc4 or later"}, + {type="label", text="- 3.0 or later"}, + }}, + {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}, + }}, + }) +end + +local function showLvglRequired() + lcd.clear() + lcd.drawText(5, 10, "LVGL support required", BOLD) + lcd.drawText(5, 20, "Color LCD radio with", 0) + lcd.drawText(5, 30, "EdgeTX 2.11.5+, 2.12-rc4+,", 0) + lcd.drawText(5, 40, "or 3.0+ needed", 0) +end + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if lvgl == nil then + return + end + if VERSION_CHECK_ENABLED then + versionCheckResult = checkEdgeTxVersion() + end +end + +-- ============================================================================ +-- Interface: preCheck (LVGL-specific: version/availability) +-- ============================================================================ + +function UI.preCheck() + if lvgl == nil then + showLvglRequired() + return 0 + end + + if versionCheckResult == false then + if not UI.uiBuilt then + showVersionRequired() + UI.uiBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + return nil +end + +-- ============================================================================ +-- Interface: invalidate +-- ============================================================================ + +function UI.invalidate() + UI.uiBuilt = false +end + +-- ============================================================================ +-- Interface: onDeviceLoaded +-- ============================================================================ + +function UI.onDeviceLoaded() + UI.invalidate() +end + +-- ============================================================================ +-- Interface: onNewDevice +-- ============================================================================ + +function UI.onNewDevice() + if Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES or UI.folderWasReady then + UI.invalidate() + end +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + if not UI.uiBuilt then + NoModuleDialog.show(function() App.shouldExit = true end) + UI.uiBuilt = true + end +end + +-- ============================================================================ +-- Interface: handleUnsupported +-- ============================================================================ + +function UI.handleUnsupported() + if not UI.uiBuilt then + Dialogs.showMessage({ + title = "Unsupported Firmware", + message = "ELRS 1.x firmware detected. Please update to 3.x.", + }) + UI.uiBuilt = true + end +end + +-- ============================================================================ +-- User action handlers (call App for business logic) +-- ============================================================================ + +function UI.openFolder(folderId, folderName) + App.enterFolder(folderId, folderName) + UI.invalidate() +end + +function UI.switchDevice(deviceId) + if App.switchDevice(deviceId) then + UI.invalidate() + end +end + +function UI.handleBack() + if Navigation.isAtRoot() then + Dialogs.showConfirm({ + title = "Exit", + message = "Exit ExpressLRS Lua script?", + onConfirm = function() App.shouldExit = true end + }) + else + local entry = App.goBack() + if entry and entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + UI.invalidate() + end +end + +-- ============================================================================ +-- Command popup handling +-- ============================================================================ + +local function onCommandCancel() + Protocol.commandCancel() + UI.commandDialog = nil + UI.invalidate() +end + +local function handleCommandPopup() + if not Protocol.fieldPopup then + if UI.commandDialog then + UI.commandDialog = nil + UI.invalidate() + end + return + end + + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + UI.commandDialog = nil + UI.invalidate() + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then + UI.commandDialog = CommandPage.showConfirm( + Protocol.fieldPopup.name, + Protocol.fieldPopup.info, + function() Protocol.commandConfirm() end, + onCommandCancel + ) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then + UI.commandDialog = CommandPage.showExecuting( + Protocol.fieldPopup.name or Protocol.fieldPopup.info, + onCommandCancel + ) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + end +end + +-- ============================================================================ +-- Warning handling +-- ============================================================================ + +local function handleWarning() + if App.shouldExit then + return + end + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if not UI.warningDialog and not UI.warningDismissed then + if Protocol.elrsFlagsInfo == "Model Mismatch" then + UI.warningDialog = ModelMismatchDialog.show( + function() + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + UI.invalidate() + end, + function() + UI.warningDismissed = true + App.shouldExit = true + end + ) + else + Dialogs.showMessage({ + title = "Warning", + message = Protocol.elrsFlagsInfo + }) + UI.warningDialog = true + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + end + end + if UI.warningDismissed and UI.warningDismissedAt then + if getTime() - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + UI.warningDialog = nil + end + end + else + UI.warningDialog = nil + if not UI.warningDismissedAt or (getTime() - UI.warningDismissedAt > 6000) then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, touchState) + handleCommandPopup() + + if not UI.commandDialog then + handleWarning() + + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + if UI.uiBuilt then + UI.invalidate() + end + end + + if not UI.uiBuilt and #Protocol.fields > 0 then + UI.build() + end + end +end + +-- ============================================================================ +-- Subtitle builder +-- ============================================================================ + +function UI.getSubtitle() + if not Navigation.isAtRoot() then + local top = Navigation.stack[#Navigation.stack] + local path = top.name or "" + + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if loaded and loaded < total then + path = path .. string.format(" • Loading %d%%", math.floor(loaded / total * 100)) + end + + return path + end + + local loaded, total = Protocol.getFolderLoadProgress(nil) + if loaded and loaded < total and Protocol.fieldsCount > 0 then + return string.format("Loading %d%%", math.floor(loaded / total * 100)) + end + + local subtitle = "" + if Protocol.receivedPackets then + local state = Protocol.isConnected() and "Connected" or "No link" + subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then + if subtitle ~= "" then + subtitle = subtitle .. " • " .. Protocol.elrsFlagsInfo + else + subtitle = Protocol.elrsFlagsInfo + end + end + + return subtitle +end + +-- ============================================================================ +-- Field value increment +-- ============================================================================ + +function UI.incrField(field, step) + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until (newval == min or newval == max) +end + +function UI.isBooleanField(field) + if not field.values or #field.values ~= 2 then + return false + end + return field.values[1] == "Off" and field.values[2] == "On" +end + +-- ============================================================================ +-- Widget creators +-- ============================================================================ + +local IS_NARROW = LCD_W < 400 +local LABEL_PCT = IS_NARROW and 42 or 50 +local CTRL_PCT = 100 - LABEL_PCT + +function UI.createChoiceRow(pg, field) + 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 = field.name or "", + color = COLOR_THEME_PRIMARY1 + } + } + }) + + local ctrlRect = row:rectangle({ + w = lvgl.PERCENT_SIZE + CTRL_PCT, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER + }) + + if UI.isBooleanField(field) then + ctrlRect:toggle({ + get = function() return field.value or 0 end, + set = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end + }) + else + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, v in ipairs(field.values or {}) do + if v ~= "" then + filteredValues[#filteredValues + 1] = v + origToFiltered[i - 1] = #filteredValues + filteredToOrig[#filteredValues] = i - 1 + end + end + ctrlRect:choice({ + values = filteredValues, + get = function() return origToFiltered[field.value or 0] or 1 end, + set = function(val) + field.value = filteredToOrig[val] or 0 + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end + }) + end + + if field.unit and field.unit ~= "" then + ctrlRect:box({ + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + text = " " .. field.unit, + } + } + }) + end +end + +function UI.createNumberRow(pg, field) + local displayFn + if field.type == Protocol.CRSF.FLOAT then + displayFn = function(val) + return string.format(field.fmt or "%.0f", val / (field.prec or 1)) + end + else + displayFn = function(val) + return tostring(val) .. (field.unit or "") + end + end + + pg:build({ + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + thickness = 0, + children = { + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + children = { + { + type = "label", + color = COLOR_THEME_PRIMARY1, + text = field.name or "", + }, + }, + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + CTRL_PCT, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + thickness = 0, + children = { + { + type = "numberEdit", + min = field.min or 0, + max = field.max or 255, + get = function() return field.value or 0 end, + set = function(val) + field.value = val + end, + edited = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadParentFolder(field) + end, + display = displayFn, + active = function() return not field.disabled end, + }, + }, + }, + }, + }, + }) +end + +function UI.createInfoRow(pg, field) + pg:build({ + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + thickness = 0, + children = { + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + children = { + { + type = "label", + color = COLOR_THEME_PRIMARY1, + text = field.name or "", + }, + }, + }, + { + type = "rectangle", + w = lvgl.PERCENT_SIZE + CTRL_PCT, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + thickness = 0, + children = { + { type = "label", text = field.value or "" }, + }, + }, + }, + }, + }) +end + +function UI.createFolderWidget(pg, field, width) + pg:button({ + text = field.name or "", + w = width or (lvgl.PERCENT_SIZE + 100), + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + UI.openFolder(field.id, field.name) + end + }) +end + +function UI.createCommandWidget(pg, field) + pg:button({ + text = field.name or "", + w = lvgl.PERCENT_SIZE + 100, + press = function() + Protocol.handleCommandSave(field) + end + }) +end + +function UI.buildFieldWidget(pg, field, folderWidth) + if not field or not field.name then + return + end + + local fieldType = field.type + + if fieldType == Protocol.CRSF.FOLDER then + return UI.createFolderWidget(pg, field, folderWidth) + end + + if fieldType == Protocol.CRSF.COMMAND then + return UI.createCommandWidget(pg, field) + end + + if fieldType <= Protocol.CRSF.INT16 or fieldType == Protocol.CRSF.FLOAT then + return UI.createNumberRow(pg, field) + end + + if fieldType == Protocol.CRSF.TEXT_SELECTION then + return UI.createChoiceRow(pg, field) + end + + if fieldType == Protocol.CRSF.STRING or fieldType == Protocol.CRSF.INFO then + return UI.createInfoRow(pg, field) + end +end + +-- ============================================================================ +-- Main build function +-- ============================================================================ + +function UI.build() + lvgl.clear() + + local pageOptions = { + title = "ExpressLRS", + subtitle = UI.getSubtitle + } + + if not Navigation.isAtRoot() then + pageOptions.backButton = true + pageOptions.back = function() + UI.handleBack() + end + else + pageOptions.back = UI.handleBack + end + + UI.currentPage = lvgl.page(pageOptions) + + local outerContainer = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + align = CENTER + }) + + local fieldContainer = outerContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_OUTLINE, + }) + + local currentFolder = Navigation.getCurrent() + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + fieldContainer:button({ + text = device.name or "Unknown", + w = lvgl.PERCENT_SIZE + 100, + press = function() + UI.switchDevice(device.id) + end + }) + end + end + else + if currentFolder == nil then + UI.createInfoRow(fieldContainer, { name = "Device", value = Protocol.deviceName or "Searching..." }) + end + + local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) + local FOLDERS_PER_ROW = 2 + if IS_NARROW then + FOLDERS_PER_ROW = 1 + elseif LCD_W >= 800 then + FOLDERS_PER_ROW = 3 + end + local folderWidth = math.floor(100 / FOLDERS_PER_ROW) - 1 + local i = 1 + while i <= #fieldsInFolder do + local field = fieldsInFolder[i] + + if field.type == Protocol.CRSF.FOLDER then + local folderBatch = {} + while i <= #fieldsInFolder and fieldsInFolder[i].type == Protocol.CRSF.FOLDER do + folderBatch[#folderBatch + 1] = fieldsInFolder[i] + i = i + 1 + end + + if FOLDERS_PER_ROW == 1 then + for j = 1, #folderBatch do + UI.createFolderWidget(fieldContainer, folderBatch[j]) + end + else + for j = 1, #folderBatch, FOLDERS_PER_ROW do + local rowContainer = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + borderPad = lvgl.PAD_OUTLINE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + align = CENTER, + color = COLOR_THEME_PRIMARY2 + }) + + for k = 0, FOLDERS_PER_ROW - 1 do + local folderField = folderBatch[j + k] + if folderField then + UI.createFolderWidget(rowContainer, folderField, lvgl.PERCENT_SIZE + folderWidth) + end + end + end + end + else + UI.buildFieldWidget(fieldContainer, field) + i = i + 1 + end + end + + if currentFolder == nil and Protocol.deviceIsELRS_TX then + UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) + end + + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + fieldContainer:button({ + text = "Other Devices", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + end + }) + end + end + + fieldContainer:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0 + }) + + UI.uiBuilt = true +end + +return UI diff --git a/color/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/loadable.lua rename to src/WIDGETS/ELRSTelemetry/loadable.lua diff --git a/color/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/main.lua rename to src/WIDGETS/ELRSTelemetry/main.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/hd.lua rename to src/WIDGETS/ELRSTelemetry/ui/hd.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/portrait.lua rename to src/WIDGETS/ELRSTelemetry/ui/portrait.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/sd.lua rename to src/WIDGETS/ELRSTelemetry/ui/sd.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/sd_tall.lua rename to src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/small.lua rename to src/WIDGETS/ELRSTelemetry/ui/small.lua diff --git a/color/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua similarity index 100% rename from color/WIDGETS/ELRSTelemetry/ui/topbar.lua rename to src/WIDGETS/ELRSTelemetry/ui/topbar.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/loadable.lua rename to src/WIDGETS/ELRSVTXAdmin/loadable.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/main.lua rename to src/WIDGETS/ELRSVTXAdmin/main.lua diff --git a/src/WIDGETS/ELRSVTXAdmin/presets.txt b/src/WIDGETS/ELRSVTXAdmin/presets.txt new file mode 100644 index 0000000..54f72a9 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/presets.txt @@ -0,0 +1,10 @@ +enabled=1 +source=92 +autoPushVtx=1 +pushSource=94 +p1=4,6 +p2=5,2 +p3=5,3 +p4=5,4 +p5=5,5 +p6=5,6 diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/hd.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/hd.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/portrait.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/sd.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/sd.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/small.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/small.lua diff --git a/color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua similarity index 100% rename from color/WIDGETS/ELRSVTXAdmin/ui/topbar.lua rename to src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua diff --git a/global/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua similarity index 100% rename from global/SCRIPTS/CRSFSimulator/csrfsimulator.lua rename to test/SCRIPTS/CRSFSimulator/csrfsimulator.lua From e7286624e1577fcbf77f766900b239d36bcb6da6 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 14:57:59 +0200 Subject: [PATCH 035/218] remove per-byte lookup table allocation in string/options parser --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 8d7d3d5..70e62ef 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -332,6 +332,8 @@ function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) local r = last or (isOpts and {}) local optParts = {} local vcnt = 0 + local charUp = CHAR_UP or (__opentx and __opentx.CHAR_UP) + local charDown = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) repeat local b = data[offset] offset = offset + 1 @@ -346,10 +348,13 @@ function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) elseif b ~= 0 then -- Translate legacy OpenTX arrow bytes (0xC0/0xC1) from ELRS firmware -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs - optParts[#optParts + 1] = ({ - [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), - [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) - })[b] or string.char(b) + if b == 192 and charUp then + optParts[#optParts + 1] = charUp + elseif b == 193 and charDown then + optParts[#optParts + 1] = charDown + else + optParts[#optParts + 1] = string.char(b) + end end end until b == 0 From 9adb687595452322faeacebb96243b8c8d94a348 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 15:03:11 +0200 Subject: [PATCH 036/218] use table.concat instead of .. --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index a2a76f2..a6f71a8 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -633,14 +633,14 @@ end function UI.getSubtitle() if not Navigation.isAtRoot() then local top = Navigation.stack[#Navigation.stack] - local path = top.name or "" + local subtitleParts = {top.name or ""} local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) if loaded and loaded < total then - path = path .. string.format(" • Loading %d%%", math.floor(loaded / total * 100)) + subtitleParts[#subtitleParts + 1] = string.format(" • Loading %d%%", math.floor(loaded / total * 100)) end - return path + return table.concat(subtitleParts) end local loaded, total = Protocol.getFolderLoadProgress(nil) @@ -656,7 +656,7 @@ function UI.getSubtitle() if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then if subtitle ~= "" then - subtitle = subtitle .. " • " .. Protocol.elrsFlagsInfo + subtitle = table.concat({subtitle, " • ", Protocol.elrsFlagsInfo}) else subtitle = Protocol.elrsFlagsInfo end @@ -780,7 +780,7 @@ function UI.createChoiceRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - text = " " .. field.unit, + text = table.concat({" ", field.unit}), } } }) @@ -795,7 +795,7 @@ function UI.createNumberRow(pg, field) end else displayFn = function(val) - return tostring(val) .. (field.unit or "") + return table.concat({tostring(val), field.unit or ""}) end end From 08e1e4e2962182e176100cebb5c08fbabfffbc48 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 15:18:49 +0200 Subject: [PATCH 037/218] remove .. in favor of shim.tableConcat --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 70e62ef..7dae76e 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -409,7 +409,7 @@ function Protocol.fieldFloatLoad(field, data, offset) field.prec = 3 end field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit + field.fmt = shim.tableConcat({"%.", tostring(field.prec), "f", field.unit}) field.prec = 10 ^ field.prec end From 446bc17fdfbace79b1793f2a2ac7f330432fdeb9 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 15:23:52 +0200 Subject: [PATCH 038/218] do not pass shim to ui --- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 4d8ca80..721a6d1 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -125,7 +125,6 @@ local function init() App = App, Navigation = Navigation, Protocol = Protocol, - shim = shim, VERSION = VERSION, } if useLvgl then From 8274ca0ad02c8bc08a7e6bffe7e5a1bba8e954f5 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 17:04:29 +0200 Subject: [PATCH 039/218] use sparse keys in protocol field allocation --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 13 ++++++++----- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 10 +++++----- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 7dae76e..2c08d98 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -378,7 +378,8 @@ local function fieldUnsignedLoad(field, data, offset, size, unitoffset) field.value = Protocol.fieldGetValue(data, offset, size) field.min = Protocol.fieldGetValue(data, offset + size, size) field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) - field.unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + local unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + field.unit = (unit ~= "") and unit or nil if size ~= 1 then field.size = size end @@ -409,7 +410,7 @@ function Protocol.fieldFloatLoad(field, data, offset) field.prec = 3 end field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = shim.tableConcat({"%.", tostring(field.prec), "f", field.unit}) + field.fmt = shim.tableConcat({"%.", tostring(field.prec), "f", field.unit or ""}) field.prec = 10 ^ field.prec end @@ -418,10 +419,11 @@ function Protocol.fieldTextSelLoad(field, data, offset) local cached = field.dirty == nil and field.values field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) if not cached then - field.disabled = vcnt <= 1 + field.disabled = (vcnt <= 1) or nil end field.value = data[offset] - field.unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + local unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + field.unit = (unit ~= "") and unit or nil field.dirty = nil end @@ -435,7 +437,8 @@ end function Protocol.fieldCommandLoad(field, data, offset) field.status = data[offset] field.timeout = data[offset + 1] - field.info = Protocol.fieldGetStrOrOpts(data, offset + 2) + local info = Protocol.fieldGetStrOrOpts(data, offset + 2) + field.info = (info ~= "") and info or nil if field.status == Protocol.CRSF.CMD_IDLE then Protocol.fieldPopup = nil end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 36bd2bf..d8dc906 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -341,7 +341,7 @@ end -- ============================================================================ local function fieldIntDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, field.value .. field.unit, attr) + lcd.drawText(UI.COL2, y, field.value .. (field.unit or ""), attr) end local function fieldFloatDisplay(field, y, attr) @@ -349,7 +349,7 @@ local function fieldFloatDisplay(field, y, attr) end local function fieldTextSelDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. field.unit, attr) + lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. (field.unit or ""), attr) end local function fieldStringDisplay(field, y, attr) @@ -540,11 +540,11 @@ function UI.drawPopup(event) end if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then - popupCompat(Protocol.fieldPopup.info, "Stopped!", event) + popupCompat(Protocol.fieldPopup.info or "", "Stopped!", event) Protocol.reloadAllFields() Protocol.fieldPopup = nil elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then - local result = popupCompat(Protocol.fieldPopup.info, "PRESS [OK] to confirm", event) + local result = popupCompat(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "OK" then Protocol.commandConfirm() @@ -556,7 +556,7 @@ function UI.drawPopup(event) UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end local result = popupCompat( - Protocol.fieldPopup.info .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", + (Protocol.fieldPopup.info or "") .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", "Press [RTN] to exit", event) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status From e16f16d5baea82e1193a39ab98e5343db0f80e14 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 21:20:06 +0200 Subject: [PATCH 040/218] Update readme for unified lua --- README.md | 123 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index fa83f5d..7a02afc 100644 --- a/README.md +++ b/README.md @@ -1,80 +1,103 @@ # ExpressLRS Lua Scripts -Lua configuration scripts for ExpressLRS on EdgeTX and OpenTX radios. Two variants are available depending on your radio's screen type: a **black & white** version for legacy LCD radios, and a **color LCD** version with a modern LVGL interface and widgets. +Lua configuration tool for ExpressLRS on EdgeTX radios. Works on both black & white LCD and color LCD radios. -## Black & White Screen Radios +The package also includes two color-LCD widgets: the **ELRS Telemetry Widget** and the **VTX Administrator Widget**. -Use **`blackwhite/elrs.lua`** for radios with a black & white LCD screen (e.g. RadioMaster Zorro, Boxer, TX12, Jumper T-Lite, etc.). +## Features -- Works with both **OpenTX** and **EdgeTX** (all versions) -- Compatible with **ExpressLRS v2.0 through current** -- there is no need for version-specific scripts, just use `elrs.lua` +- Configure packet rate, telemetry ratio, switch mode, model match, antenna mode, TX power, WiFi connectivity, and more +- Compatible with **ExpressLRS v3.0+** -### Installation +## Installation -1. Copy `blackwhite/elrs.lua` to `SCRIPTS/TOOLS/` on your radio's SD card. -2. Delete any old versions such as `ELRS.lua`, `elrsV2.lua`, or `elrsV3.lua`. These version-labeled filenames have been obsoleted. +Copy the contents of the `src/` directory to the **root** of your radio's SD card, preserving the directory structure. Delete any old ELRS scripts (`ELRS.lua`, `elrsV2.lua`, `elrsV3.lua`, `expresslrs.lua` and their `.luac` counterparts) from `SCRIPTS/TOOLS/`. -### Downloading from GitHub +When done, your SD card should contain: -Click the `elrs.lua` file link, find the **Raw** button near the top of that page. Right-click, **Save link as...**, and copy the `.lua` file into the `/SCRIPTS/TOOLS` directory of your radio's SD card. +``` +SCRIPTS/ + ELRS/ + crsf.lua -- shared CRSF protocol library + TOOLS/ + ExpressLRS/ + main.lua -- entry point + protocol.lua -- CRSF protocol handling + navigation.lua -- folder navigation + shim.lua -- BW compatibility shim + ui/ + lvgl.lua -- color LCD UI (LVGL) + lcd.lua -- black & white LCD UI +WIDGETS/ + ELRSTelemetry/ + main.lua + loadable.lua + ui/ + ... + ELRSVTXAdmin/ + main.lua + loadable.lua + presets.txt + ui/ + ... +``` -## Color LCD Radios +The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. -Use the scripts in the **`color/`** directory for radios with a color touchscreen LCD (e.g. RadioMaster TX16S, Jumper T18, FlyDragon, etc.). +## ExpressLRS Configuration Tool -- Requires **EdgeTX 2.11.5, 2.12-rc4, 3.0 or newer** (uses the LVGL graphics framework) -- Compatible with **ExpressLRS v2.0 through current** +The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. -The color LCD package includes three components: the **ExpressLRS Configuration Tool**, the **ELRS Telemetry Widget**, and the **VTX Administrator Widget**. +### Architecture -### ExpressLRS Configuration Tool +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and run-loop orchestrator | +| `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | +| `navigation.lua` | Folder and device navigation stack | +| `shim.lua` | Polyfills for BW radios missing standard Lua functions | +| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | +| `ui/lcd.lua` | BW LCD interface (text cursor, popups) | -The main configuration tool (`SCRIPTS/TOOLS/expresslrs.lua`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio: packet rate, telemetry ratio, switch mode, model match, antenna mode, TX power, WiFi connectivity, and more. +## ELRS Telemetry Widget -![ExpressLRS Configuration Tool](screenshots/tool_main.png) +The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). -### ELRS Telemetry Widget +## VTX Administrator Widget -The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). +The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. -![Widgets on home screen](screenshots/widgets.png) +## CRSF Simulator (Testing) -![Telemetry widget full screen](screenshots/widget_telemetry_fullscren.png) +The `test/` directory contains a CRSF protocol simulator for development and testing without real hardware. -### VTX Administrator Widget +**File:** `test/SCRIPTS/CRSFSimulator/csrfsimulator.lua` -The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. +The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. -![VTX Administrator widget full screen](screenshots/widget_vtxadmin_fullscreen.png) +### How it works -### Installation +When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. -Copy the contents of the `color/` directory to the **root** of your radio's SD card, preserving the directory structure. When done, your SD card should contain: +To use the simulator, copy the `test/` directory contents onto the SD card alongside `src/` so that `SCRIPTS/CRSFSimulator/csrfsimulator.lua` is present. The simulator is ignored on real hardware. -``` -SCRIPTS/ - ELRSLib/ - crsf.lua - TOOLS/ - expresslrs.lua -WIDGETS/ - ELRSTelemetry/ - main.lua - loadable.lua - ui/ - ... - ELRSVTXAdmin/ - main.lua - loadable.lua - ui/ - ... -``` +### Scenarios + +The simulator supports multiple test scenarios, configurable via the `config.scenario` variable at the top of the file: -The shared library `SCRIPTS/ELRSLib/crsf.lua` is required by the tool and both widgets. +| Scenario | Description | +|----------|-------------| +| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | +| `disconnected` | TX present but no RX. Shows "No link" state. | +| `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | +| `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | +| `armed` | TX + RX connected with "is Armed" warning flag. | +| `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | +| `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | ## Compatibility -| Variant | Firmware | ExpressLRS | -|---------|----------|------------| -| Black & White (`blackwhite/`) | OpenTX or EdgeTX (any version) | v2.0+ | -| Color LCD (`color/`) | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v2.0+ | +| Radio type | Firmware | ExpressLRS | +|------------|----------|------------| +| Black & white LCD | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v3.0+ | +| Color LCD | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v3.0+ | From ced30dbca2a0443ebfdb17de7d50c3bc4d0a7586 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 21:54:39 +0200 Subject: [PATCH 041/218] Add screenshots --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 7a02afc..f9353fd 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. +![ExpressLRS Configuration Tool](screenshots/tool_main.png) + ### Architecture | Module | Purpose | @@ -59,14 +61,24 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t | `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | | `ui/lcd.lua` | BW LCD interface (text cursor, popups) | +## Widgets + +Both widgets running side-by-side on the home screen: + +![ELRS Widgets](screenshots/widgets.png) + ## ELRS Telemetry Widget The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). +![ELRS Telemetry Widget](screenshots/widget_telemetry_fullscren.png) + ## VTX Administrator Widget The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. +![VTX Administrator Widget](screenshots/widget_vtxadmin_fullscreen.png) + ## CRSF Simulator (Testing) The `test/` directory contains a CRSF protocol simulator for development and testing without real hardware. From c8ab817932bfcaa2910c8e3e60e1c58b13c42b7b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 21:58:31 +0200 Subject: [PATCH 042/218] resize screenshots --- screenshots/tool_main.png | Bin 37679 -> 44910 bytes screenshots/widget_telemetry_fullscren.png | Bin 31044 -> 36548 bytes screenshots/widget_vtxadmin_fullscreen.png | Bin 26645 -> 30282 bytes screenshots/widgets.png | Bin 22713 -> 36254 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshots/tool_main.png b/screenshots/tool_main.png index 41c01018154562e8460c69f01b00ed91b4d420b4..3281e94c87e3340edfcf7e33fc9ed6284bcd8adb 100644 GIT binary patch literal 44910 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 c)a1@8J5kc2Gk{CnP256qn(A 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 z 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 literal 37679 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�_wdu(b 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 literal 31044 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 diff --git a/screenshots/widget_vtxadmin_fullscreen.png b/screenshots/widget_vtxadmin_fullscreen.png index 923e4a62a436fb5ae92ca06928ed302503cf2e9e..09ceac413fae9884f794a7240909056ccd4299e8 100644 GIT binary patch literal 30282 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 literal 26645 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

OlLO0y%?eAM*>!iw2+}6 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 Za71X^iM8Rc+>6Z9IvR$z%hc{h{tp5VOlAN8 diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 2c2ff9147e7b2c32f9ff7634848050b15a659b1b..04c26a9bbeb00d87beae22c652b4d7f826da759f 100644 GIT binary patch literal 36254 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 literal 22713 zcmcG$WmJ`2)Hb^5l1^y}>Fy5clr9Nr=|;MxyIVHWB@NO7(k0zUZIIY>$GP!&-*22> z->-A_7!HMd-z(;vYu0rwBj2mYqN5U{fMg)Fih=g(p{DtBq zr{@X+Vf4NHfk|b;AOV3WLGn@(nxAtHR|Bj`JoCh!tl~*yde92lSmna+do*yqyA0v9 zqV(C+^{K$+)2CY$a-%h7hdk_&RZTM)3awkzb`;rW?{r2syWuvklsNzNQ*Zn3kh0~X zCYqp$lC6o#-8lL-orBwLl+Kd2R1bZo5{dWwy4Wx3F*&X~d%ehfOyo!&>M;Xqs|)|J z`$zANeB9^9_<;WD<4DAnNRX;_+Z(BuPs+r4r3YNOX)dT`wPF>O>gB_@v4|`V`l_dY zM1X}2eAHkjR6xJ?V2Q(K9S~lAfxUBj2u}%;m56xx{9UQX4{Q`QR}*Z4 zJM3hix!cQdVDC#i1WHiJ$A1rJYpvKo*qJdZFSBdbAd9bC>a4+C_Niw)$h_M?Fah@_dm3AC zgxz=&k00SVzjqP!P@ldYvA;PR-WxJs+xl6*H*OQOecZ$3d%C0^{f&bo~&1{E>@QRuNzH_pfV$+uY zYQf=PmD!}b1POcdEHTtQlMsFE6F+wS>z+W-%aOwkmRbKFsmX6KDF*{BpYL8IB&F%2 zrPnm^EbQbFp7GRrROk0q-F1w7Be8C&it?XyEu@hs(F2wPU4^O&QRqyG8|dydnQZgq z_F)G1?K8h!49OA~*BhCdL`S52;x@CJdjqpN%EAG9^YU^zHEnDlX-Z|}uHQ9IlK|-s zGB?@@GZIIlIqLhW9+i?381c7*C}TxfvO)V=!65F)Zpqx3!GC+Ja;8O~oL<+%?P=`N zY-I5gQPRVmYowFj_25?Qmh`nCmj1VVS_VOo-rWd}hp|CNVkoYsqLL&Z`|I2nHI+ID zXEPO8xk8LRL;tDG#w-?|@_5z(=AWSybmK9*j!XBh^KM);t$b1{#B`w$>QH90+OZw3 zyiN3CL6_ldtEx4$dg<&kZmYrUlvsiK-8IH;9$B?k8^jo7%sfRRg;WawVDs;}$? zhov{`Di$+48t-K&a33S4uq|xaZ0svx@I=B-zaMY!#$!@g@y5znZ+7HfREedi`Je)9 zaGgr15#g_6$N3{1=AW5PF^-S~(xe4$d1{i{BCNnpn$$O6Nj(?(cBQmoiz`fr#Q(YX z{>uK=7Ap+zK^ay!cFr$V+GH~kQK0DA_aQE7O*v4fLCm8(rTy$J=64mua!uZhf7@5M z&bfgGJRI2Yfh{d9O!Clhab5&bmDWy8`5QAZPy5X}E5cB8cS~i#G+YabtFhv2;E5Te z;MMcFc(nEUmjrNX6P9Ux_Wv$(m?DsS3F4YUm8AQ#rBXtd&ZKhnsZp?omURAxzxka~ zO@ae~GV{l?Y@GQk6HdXGxu(j?^`@6~S&@jZCya~Vd`Hy$=v=(J8XKfxUc{pP`6QoNAQvQ&U@Um@d9=uoLX-{=2=OrnA>2*ncNYGDBHMK7zD;H9# z6jCI|bJMRA+PK4vsfV$N|1+wcL7~?SNua0W`jx3KY*(=M6OcHSc7H`_8c##X=sp~F z-iiW8*W>g1=77DZI;mt)xrNfV?&fS4+sVoi7>Gb=VyF{8~fXQ+<(?ku7xS? zt9*rD^J>aGwb)2xDv&05;-A~oo-RkE{3&Q+qVWX!R-zSXou6)$h48=cHEE%XBl0e@ z@mufOSaVr5Nw4{31 zcjEvB_IEPQO={OZgm7Gkyu1rMR{(7Wkn__os&;rkrlIcKuTYv7R={**kK{!4H;TP{ zDdO+Xa(FYQ3Mb#i={LO89L0RwlkiM;Q}EP9#PcWC*7WDf0ZM zvcKc5n_bVRH5IE7<{h!UaA1tDZ=UI14y8Q1*7`3dZ~yY2Tf&}w1&8&r5UTRmh?M`GA+A^A|BR&6^8d4e=Bhm4 zfHCtdNY*ozp6`DTseH~})$)5h5kY{{;Anw|v06XTdAy8_D7RNeq=d`w$NEu$w=zME zdb9axg08RmJPN6FjV7^*cO&Qs;FSO^>8~I=Bx5ZwE*=*$mlAccQuAhuOU{D!@?}k; zLkoS{op@03+exU(oBX;k-pQAPKtn8>f7xK0?v+;(IXLlEpI2J@V+WP$zL^=4y%Nt; zEP$FaKQ8R|9tq4RpNJG~h~7$()AOIo@7@~6!5^G0ad{YiDTYB&?(MTj=&b?5HTdoq zgRZ7)7NV3@@?INJZzNh`&XF14<^ms{%wBa8hA4=#z=o)cc5CMeiwYAJDM3n8$WVxj z%!js&sd|qCMG}vASkTC%0)B|4IDotZXJT>SA!D0B5fvEVBK1-bH?M5s zK*g$p?$PlX5s^Yl^LcI?IU-XQZ^ItjzF8hz@vF*<*CKyw&kkqFWu1mIxo;ts-`nVP zUrg*hyN9$2lnFVoYc}`19XG%4WGAA86)J;MB^|_)WMNJ%T=>-|oX@Ij&(5-e`uiB1 zJYIo~Vw&G5HC2*9=yubS7$L$VWIBA==_JJ;m%6|vqlLO8UcgzlX{DRrucd1HIz@z& z%WL^bq^fBRP87b$ixL_3+uM667m#Dt?ZRO>M(a8WVr%_6CZ%}hPdbhWbzs^D_4y8P z_z=t5sP!Hfyca|k2r!g^!mOh<=S4*QvBBGLbT4@}9_W@>)1`#f%(V|DQG4wdgyq1tsP3EBp z#52u%$Bf*4(dALpoGCQbbATzjF!5urWeC*-hGtDLC@}mNkD1FW%)Jvl>zIY`?c^ zd>4y)uF2tQg8!q6f9*z&#fGQ@NA|N!Q1L!=>eF_=?@^Z^k@#Ly@JWp5(F`>z@^Q-K zc@*Ywa8zR1{zc>ZvCnp2nB1N3_2G4~<6gs~FQ*@QCn?J2gZttYk(5;9FVMHZZfz8^ zc?)`Z{4B&6qqxHBK+Hs>C=14*Rl*zrFYHNZUhWOem+A12%dOBhBEiw6xTdyQHn()3`R$h@>`ey z^D%3!pFfX^H_ni9XY?V~-VQR|r_Xk!9~GcZ1y1AMB2-y*m0R@>`~`+rS&)j2nxgSK z0?eM+%oo3RA3=BSL#!Zq7MstPnhP%Pq~*&6=5>VzCZt@>R)19w%K4V;;GMH~%2s3i zP#}&Ra>#am1!^zGQ5&nerE&98>EGM=JWjF2N^FuVB2EbFkinO{q#;$ zv2MFtbJW9_`UWdhsYLipycV9~Nt-Wtx#;35v|O*4IOm2Y>&erpEwtXTq0lP8@=kKE z8rDlx@2qpyGS6mqE(wJlP;c>ib}0=@D4X6+msU$+t8fk5U z`>sho>R)ww(5lflEFe=>^G-?=-PRwzk4S#d@@aE8&&sYdnFe{>Gk%noqRsR3bnl<6 zV%4Kza)i_7AI^Wjuj7oGBMJVh^}fD8ap@90qIA=dkJrrT0>usp^1&=!i<@ibtAC|Q z->_%3Uso7(wfV7>TKq;NwR^HH!0c{aJvkz>S!l}dZPMBRQR03Z(K|p2YmcHpnlj69 z&Rw}+XGyGa!(@9|Ve?EuE;{4D(2ADAcZnKkk!1z3?AW4M*zH-s?-+I3V>%eb0FB6n z-Rf5{m+nvD((OLBc`Br-B?B@oiRba4&LeM(m?%>4R(%Zj-G9h1^J(Ab{gfr@-D}H; zJ^MS00){~w`ZP)(QMBbh=oO`YMJ8}gCU#q!RUa^~!T?e1F0H`a6Tlu0&Vw(r$+FPw zCn{rCT@`$CrtvGO225Kg0K0qE82)#VDsVoj3Tz@qX|OZp^%?b0g2}&G(5ZT7uVy^W zvtArt>n|bi8J{utDr$C(&bNk^OXyA3d}QKy9A>uYtVo2^N^akN+d)j8y5;MtIeZ}4 z5+NStD~if|yTfBRQeP9kD?ZSkuf6GGYdfUrKI)LH|H})Ao_#MHuv8Nf?=qoS-f4_U zOVQU^JNGT-swxeG8d(IC?PM$FyuVD0dV1ICb{xs!0_vk|?a#5{EIMq%0hD)^Cq}*g zE>G2Tv&pozS9hG$no8emarXO9F4P}QJ}i6=#^?j>UNU`fd zJpDZ;TXI*;jyAJ3Z&?|Enu>}kE--~s$Xc$nwUFr6dOh8vUH#bUb(@Dnl9M}o0@s9& zd^u5O)Yi>FCS`GoAJY^N3d1Qp{`?6yM0S|)Ey!=q^s$RM$p~R{tS9NMY1|q~{Oaz{ zUGWinwrGP_WAj!AWn?eBYu4~`U$jtXvq``$%Q7H$XkcY-(h%Wafai#pvtm=XcH)42 zL&a!479WVGn5Vk?s*Qw}B3jvn8;QN2*=3KQ%JaI56oufjpM@uq$iIpVnCjptPx%`J zUDhf&ZJ4|0&;Y&T-furK1~{ZpD$B9dasRGr8JN{&uEO^&bqxZOQko<|RqBEiK!nfW zUS-}}ICS>b*GoZ|zy}k2RL|z$e5!!n^Zqb)AGfWaIbvg0@eMH#h z{n5S-wMM3Gy)52jM{o2o+1vxix#(?%ERQfap<2}9$*l93Xtu5{gv2#2Yx)`vdrH4) zS5|x!KdVk2jMjnV`x1J)#1UZleZEcColNhgwj6oCHq$X5jMu;lLmc_uNx4u1XIkRf z#pXxH;ie;D#~XCDgK!tqU%hRP3Ryc0t?5th^dbCF(M^kdVvluMZcx1CvuY}TpXM?i zy!Z0OA1ag@%IzMhI>pI34 zd53vxcg?2*MqSnZO})``q<;5f&XePdnU7{fu*vI3pq^UI!W73T2a39=4iF`>`~~@m z-tlh|W2cV$WvtAp611>m#=SL$9ZfcvLmjwr9RVf-APWM{d!9$idso8ZGyBQ#XTw-n z6^ii`FliSpTytg3d~1L1lRArqalJ2O^gb9ZH~XxM&J3^bGX-FW`=DN3SlA z0aOPz(mkxHzZ|N)oSOH=K^E{l74#8a*m~mmNvY5*4(9>E&A$zfCebMv;xUZIfExsy zUY{kdTxpi5$TjNxJSCOOPX=8CAZ{9H)A2FBlUFvK(*%bLOdN@A*S}g+bilS+w`OZ? zu!q9r8=@;tpbIK?ge<`w*b1@pCRan{l@$ifLpRTpXR;~Ndcv{V|x^BCs>}#%Rp>q~W zdY;I%gCw?U#S28D*^sbEm3K3gjkujpUMAJaBi_||BQc&V{hQgenm7o>-*Y-9I72nT&*S`)Ph4s{dd7eJeHhkDx ztm}=lk(2b|c+zqQ9Vhkx|e2S<9q&xR}z z&$WH6$N!>p+4zzdJ5fsjCL-6RI%t~9Ip-mLOWN29W}~XvhvVewy?``u_=dB1|zkHIFI7|}=7^g7-sn6so(cUeY^s7K7L^MmFI4Cv1@@}DA#&K9seqjYW0 z_}(P)FSeN3uhKvUv1@ChpoMnq_+dn$bsV-^)=FmTs+TZY5D^<&3kfa(KSf>Z@M%gB z`B}E~a6W-zkcl8P#-BZSsvt2bzl6m`+7q>WXm+pefix>?6h#!!#ZjB!#_IN%*OLi! zsNJ6Y(Ihk%3oJ-2mT>wv-V?G~vSOS0Mj*fMn4&TD(HH1hqXUy%0FTmr`Nqc;+-^#& zHu3#F+C~${Q5;q4`=DYIhvnQZ;pQvr`d>MmZ61jEMk^Ln+-zPOZ*@@_@3yP#ok@+G zf!O^&zAAi2eREIvjL{24=frwbPJIUV+7FuZeYJR$0pD=s)zB@?mvxfL8+V}j@tr3- zwDb3u^^tt&8*Wr&^1a}aemIcaC@u5Q`eK7Ldd<^kzRsJ+d96?DLjeNEEma36YHnl# zh-K1}4@NT_p0pkZZvl%D7y?7amp#v-v402$^{gce#8Wh8B9V6y)<3MAd(_bK71>8) z0%~TVejnMoa-OZO6_X)I4q=ONR^gvQ-6J~;cXV~|&pp!(5~emg+w>ZMs2B-58oW2% z))|)wXiKxn)c6k1@ck~Hk`a-o@Hn8M+kpItQbeM_N|1$05AZWtGept*m9p|? zpV%@I60qxnvY{xZW+{hIvXH{VCihOT&bG^NBhl@wi3%u*O`;=V2xo9GbzcM6a(w5 zWkehoyP78Hb!AvnWL;Fga$fTXSZ&@+&9jK^B;Z?a?o9X-owgfp63x97APYpr;;AzJj}I! zIQX3PlP!?YL`dGFVC$A+z&-n|GeI#*+D07qDb~*o?KF zr>Nq1Y&G{A-sVLk+qxP{4X)C$%3Ue4@0CJF-1N>VUlIz8A=C;X^s z5FJ8UlHfa=i|Ti(Fm~mMR}WQ4GdqL}pNbbQzM= zgujYedB1f)If)|6r2?nUQ=&G-qs3mur}yp){+KEbYWYvLbulDXnOtQ&Sb&Q&H zQL9_!@#;g_*jq6ECO5i5U~n-Gz{8(?bPw3~m{hA%D84`Wqb z5?yPWeJWO}1uSgO2NJsV<4t9tERYe7!jyr13vu!Y?xUD)K z(^G}tNCVa}3aL6rFc*&$>P30p0e9R$oaojsiB*g(~&`&0{9Y157aooM#p zJwhd3DZN(QDVkYRZmhZ@l=vVO@Aoy=fz2>vJV5_~Bf{F^n@j%$=#C)M7UfbZ2U!!jvt<@{>mdx4DwQL0jImbZamC-TtaV zXz^Q?);EAVVk&(p#5_S0pNsvdaLjY8nB?&fu$7{x#@66cS2(y$pSiA)WwAHw_mR>bsT z9NfSG&gM$4xo~JzFOTXpI@lX16GltLR(sSP+E)wdpyRmep&x&QDvlyhgRN8=@0Moc zu;&&F@;@@J_7AmzYsc0bCitpt=7B7@ml4Aa@ zZY!r>qbn-YA7%e{2If_>P^}nQAC$umf?-X_vy(PGD)lV+eWmW4V?0Ww3 z?E&4h{*ldEfcA^5zeT?4Z}%JPS)u!Z=hLq{5ZC8>SCBXCLu7zx9v^b_>W|2D{~OcA zgK8`_FTl^u)*t|*)KZJY&5PQr_3j~!Pb)`8gmo!K%wV%3c#NKtZs*S zCKJBoQ&;Qbe*g*6!DG7r*&X|SWb1aw!18gW>@3wYxw%|`5XjnwEP&%Xt-*P?;ip4!|Rix#sW|tdD*99}UrjnIO|7<(XDTHmg$PhNAIbqT;9hL^>BbbimT;q1UU?piI`c*L}Q45A)8wPDHx!) z=;yfbD&%tRG@O_;622zsVz)%esGENnWPP^vHjy_nR%cROpaXvpfi59Y28yC? zy4gGF9|@l%oAgM_obR}E+yK>$b}Ug45MV}Wqv8bc&)`~1;Rp6JTei|q6{-+(CQf`p z;tDwi6e@LhT24&xUB%SPZBSstWC}L2C_KpG4$CcPjrQ{*+83_RMD>Eq5iXux2A!ApCgs+qVh3Z{jUG=OAoKp~w~ZCbnHo;)EfX9> zf63hP>MZEEc|NtBP`+RN-9{H+Y3TI*`LUqx*qU>LvOL$9?@f-2M9vvMkiq@S{1CUh zhU#%NK14W=w2~|| z$wVc1W=u;4)d*`di|P2}5>gG*(-D5Nk7k2B*L?)CyS^=si}qEt^(K!9FxqfV@X5~m zwJ`0rAN-FQ5!OW_|J7!VqSg#UOiZcKoFcdbFA03(JT8y|BUE}k?-iA6X<`#2_&$a^ z>Xk&Rscx=kLsZd%)3ct$_`zTBG4F86 z;5cej%2uBCd0wMDsw?0WYS-NQ((LMO7Kjfoe(SuMAD=$I!2IH<7~C2eYow(bvi=s- z({=^6>C27>RXa~d1b>jAm0I-Q!2P|R8B+j$^mU9d;FZ{t!SD)7Tp zXGP(L|GUBx0BX~u4r4Tp6{@6myX&ogNp5>r4_WVYB?|+A3Qp6YGv84cs#y0-J zp?NmIt9Yf-J52wk(ckzVRPz6YGivv|JfZ@FzoQbo3GF;=6vlXJIdU$l@pa(nKoA$wQFA1@ ziJ#Eq6dbpFqrFZQ3w?LlYX6as3DH6x-R9%A`-U2xE);3)X6DDyF^!Y!tM|Bp%V?y; zh*>%(!9uvO4smeude2@R6bs7-zN2l);&xQ^UCU?ZJb~tr&i~T206R7;4;v z&!ZB&f_fMw2*B)4LVh>lR^i)nfjA7H6AwNdMV^w-unRHSahR$wwjZi2JqUKF+v^ZD z_(|@s0uzKoN^+Q@IZcM-peDp1bbk@{n{OoiuMIQfu2Zo$##^2ouU)b!q=UHBHX5h9 z(<<4RsGVRryU^mn-q~G|u;CNhC59?pR+vA#mv!k%~+H8v#!Xaaj9(tZ{m@|Cl$g zv+S=yP0~Q}JufL32IY6=ZzBhleml=lorwn}X_CPeN&;$4Y9tFpmONhd24(!677EYL z1+4|>-h0d{6lQdVbdG?`yvN7YKW1*u#(O%w{~*j-mK)#grGMQem0{zzX7A>ES5AozfB=&NEzP09!2(N z#awcv^HxBCwS#-*B^r6mg$?YLs0~|of+@#aJw|=P>KcPh7z{FwF0rxCXlH^vwo_r^ zS?kYYG}};NrnsFr-#H1v8qkh3bw+c#e3|+S+i~WRm$?*9Xy<8DYL^9kv?~NU9Z>fuzQ{FZX z-%>I^LNnx5ZK6O;`t{qx70q0gYFWI5_l?2%I(tn0b4CK>SuxsGN(GzA1+c^oV64S4$(h>u_@Vd6g;ymwsZt5pKISnK9vSP+_^jug;@$e+~HfcrL;keaJo`1>O;HvdK03NAZZhPP*m%M3}Mh-@#EtAYsT~b+9VEH?p z3@_2uCgwll&!}CSU$fXp_8L+%vM(IdO@Oj0lp9iEDyAs(i#|>k&m|Z$qM^W?uQ0fbQ={e4ah&F2}Fe zj39o+X(dKvM8j@R^;Opgx%-(3YhInam13O?Usc_I2!js!wR1_4?Y%nd4F}dfKO#&I zDt5-*@r@MXu$lXP67UoC=+Su-pO&mB9sUVtx6iLx=b6kKYc79&st}1npi4tdB*RPv zMZ{t>mwSliCpBtDo}%ePTFgmI@PsPjZ_jKv1DY76e(Y@J?RFJ6?XExAd9`b`d{;bz zhqrMgxyq*IjB(qZS@w{HJz+p?!9WA|3?ft6bbMs=jT`;X>yD7s)_!y~YjK0d`-$GGf;NRa0T944p%gCcRuRZA(tF6>ES7V( zyP_1#mb&Q-2C(y_HW+{~u<6PGAn1T|t?5UYKZ8mGrf!Q3hb0tU&cLzg=4o%)T9}#3 zW%454MV$$S;a070{*QHQt=tevB5Kq?9tu;nS={EtON+Lz=E0cfPiRr(D=V*&e1p!8 zBS@xengmxC`D9pQHhLI9i|aLAs)f0M{;XLvA{n?MJ4o`2@PuD!FUCI?vU!pups43C zm*4qx5&mAbj7bB9iRGIoqGUvulS_TzMR4+H&^&e?Kb)R`G^&a4b2m>6GDIqCLvwJn zGN-&QA~MI*>%JBX#R0HIvl4}%^PAv7C7ckADL2};I)w>&f6Y|lFxE%hlzNS6nPKEE zNJQB8S912nv*T@5Mzskvfm8nru=^l;^5wRtqPH$Jcx~sIV|$-T$TlQXb4CH62ap)R z<&m~;mic*BXRlDR2_}e@J8NC3fAnsBYf)9nYFc4{+n2qjnpNyM@|pNh9EgpW7Ks(BHzVL7gawo8t7j zl9{IeJ1|O+-LT#z|GUHvCQ~?EeKhn!<#_I^ zUs1WV7R3-&=%(P0@Cn$EX>Y4Lemg15&K$w@9rj@9wrk&-Zm*lmk?slOnUdDk z*Yjc%%{BVh9HRMT15x+Bud<4}nog}sl`#joSpte0t5#bFIQ$*!La0FtUYgHs3*r%_ zU(}wC`qjbt%n;)l<JYT=2Xpb^vkzj5yF1sdfn{fXSNh?$Y5A#ckKH*SFNycb;_*k z@houdc;2o!Ti_F3RUuA(?&PweP4M&T(C7tyFJc$@jN65|0)5wHv6y9yHBpgQ?injD z!o^oxi7BCzc<|l%C`{VvZNu`Y-{|!%Tx9YRd%<$kyefxp?E+nyY5^w z_IEF3qP|;4`f_=GyBIl|(yR>g=v=}hNv^sKF|dRFUiLB3#Y~71`$9I5rg$OPUM(=m zUaSe&U5KpN#W4SR(XI}E>YomwvgXs#POG*Rt}6M(Tt5j=J6j9Z0@cn610POl?(tWj zym_IVAt>kkhes7}=GE3z$mO@KxUswo07JyxTl=F7-C8#3fcZ%3xt`#Pk*>Y#ZscFN zq@u^j_nv;ow;JH6gl-eKCQVHbK``W(#l7+!3n2Vtg-H{23n?+7KN{M_T=}i-P4O!g zEC4CEvyn?*n$WZre%jV1vasU2xtzz6+PJzM9w|c&ep-sQ`4oMzW1#eX2F=|2qPO7U zxF7d!eMcpibM7yDa17?vz5q#0?%eu)<3|xnXMMu0$xruE0linOevI07>X<}Q@uH@+ zU-t&an!kbz)MiQ=4f(58A>|EOx}8nL2&mL?H8wKR*4KfPOMQ&GvzBhcHL*)<7@qdr8m}3Bo*s1yhU4?uB9}Y@ z-c0A&Xo~2+znDUpGFyMm>+%i+QA?Lq^V82ZY&8+D$=SL3`;y!wdxg2M6y-w{xaC`0 z8D!KS5vL>ON6)M*uZ^P@2N7B;BpZ7Q-g)yNn77~peTP$L8 zCF3pm%^o+vK5G}@0X7Ig7*LT@)nWWmLdlyQNV&bH%*R>)VCvdcF~`&2e8LH-HJn3_ z8D9t$$;I2}zQeb^Xd-k5Jfezb`o^DSh734HGW6#=3%t=paH@EeBqy3GWV@0?qIQ8S zm-fSv3%5euXG{HWFyGJQX0!KWzH(Ji6b8$;sjDR zY4zb^ez&{{r}=2cm@HNiT7Bxe$0S`dXHNWw{mH(W<=pqtp;bdqXBdB_I)7klYu&|A&y>$xRvYK;<4av`EVv+)oEmU$d)cn_MWWp`R=FxVq|%l#)X%|z z;F~N2zXN2JBm*VKbH~Bltm%3yYLK5{{2-idIMQ=^RRG3@+DYBOycm zam*<$85^9bfWh#@GJcG_s6Efw)nJSM!-99b)Dx4?>hNPmz=dmnTIuG+{8e4L|5)b* zv`QAC{R{Z7=|m$XTt*UBU{k(zo;6c~)~E;%e28y&Td;DKS=kur3$5b$9M4+F9P5R0 zV~pr;SKg094(vUsTMTRB8hz+Pfux&NLj1E#ZRMTAS{HG?1>`NtV>maEf=&TUmY^~s zVQ@rfc`@7KfQMIW9Fkg?F2cyrnvtLPc;l4PK;vRbjfJzlCRChw7JPN}cRCX25UlI) zvM2f|B0FPc0LB;=b6MZ9GfSKdw)idjL$PssqGrE2W2+I&~s^*58m6dz^! z22tc>`^7gR5w-`@gm}SV;X%z93(hNXu~kyl=aZ>jG4k8)b)eas{M6A`Q>$A*xmQT& z=$q9=tmhE~YgVp5PJOV@!sHN14^5g8D((?cUmMR02q7tGi#l@4 z0JaQ}hU6TkP95AQm_*&0yj|kk3?-rd>s;>XZ2)}+XZ_Pi?#)?OMU44{dNF0{`7K7* z90nAPYTP(|TB5jXHrDli*{;G+>!AK{?N3xCid2$+ zRQCo{qQIGC1Zj$$jKB4wq7)NKiA%p4sa_cAd;9$~7v82c79{e=2SQvZJArxgQ^Pj$ zFT-72n1a+D*E&#v`d#sn_`s*7!@G`CXZWQX1}-73FqthpGQ7esd>JP~TaCvQegG5- z^WXKkOf6~|6P$lHZvO9l_&GcL%0W1ULLGCUBnSpIG`38&1yHw-IgI%K$5~(K{BkQY zaxNcZ^!4bGAxjn!LYfvATuX$;IEe3 zP5Gz`aO(lGuF{$|P$5XpUQ8vzjeBmsYrahUqDaR@O=7s*@wrtjwDthg`AY5xF2Va# zbA?(U<(`Bz8>fAw@LiA-&*k$>LBmH5^<47XqhuGkwCYvUo^_olG zi^EgX3k6aXtq@r}C%1S)AQ1%GG6Og6ud^)0!bX4`(fpS;In{MC7QCOqO+~0hxt1F* zN&r&`CfF+MOzz8B?5lX1p5?3n{hf;rqV4^^sbL4Nn}dB=jB0KCwcG3d-bTa$ko%h<}-+?{01+ z6LGx!dY&4%9IvT94|=E9<(Op@5om>$ybW|0zy3o$@TeeS zyhBRuO$WO}vgl!OjeQSEU8;78OBB^c=}O+ot5J`W&~%*p0HWfFjC&``pggjFV?&IU zulVuW=M9Y}dN7D|*=$T1V1Q1*ftnXU$`zGWkKvVsDchdJ04FkG1#EUwi*ux%#E4jI zy0>;5b^_-&yaaT0lAG~qH1P+nJ{S05Dy^hCZzdA~oa^5~%J+zvK&x&p>xZOy@iiCp>=Hv9#@oLXe?m6S?EAf$ zM848asn=wsK|U0*7S~PtW8<?p(CL6IiDy}|N` z=of4`fHBfNxu;fI9uJbuY;qX&>c~g8m=A@wHqiFAX9jGELXcvP3LeAN%(gD_3BMc< z=OA{zLhsu0(JS)^;K+FG%u!s)AV15Av$agQ*+<7AWrdsIu?G{;H48_XmuFMNTnf}} z-h7HDO)VGpm=|S^wL1@(>t9xA{HVOW>FYW1q*ah`rfaR`C(`vhoOpJ-Fr*y6)P7@m z<5b$V(Fwwem7bhj2<#3TRi^*cLHMT(h5mXA0WRT?x6$5S*7~n7AkKXZQ-Oh-y-|9A zi_n7iBg_AAFOmG=F*+c8XlN+7N8&oMFhPlW4GxM~a1_qAS;$ogP$z$z(Z`$9Bt<59 zqxa|3xWpHHA{JIA9}q7lK4OFr6+Nk>O;;-bM#l#DKxQtW^$Eo5;AL9HwV)ZAP2fIH zi}ZnYFTg!~x$?3{?zpCgOb5j0)Y;>Zxw%d}QIp&(&~uGw#uIq!#7aK*53y9l#3Wx? zEsNLFeisuAq1`?;Jl%;;4Y1$4+;zQ)no~1Q&+v(b`as|%)9FqxDLNZh9SDRcW8=qT zBr`QA8|9=adgZwZf*N|zj(qSV!$RE+G9gTD=Q&WKMfGR~xd|cFopPDBD1k3}wOz@n z4HYx%KH$`DMqRD1%%yvxxIx!=qkAC!(+V{pkpWIr)}u(C5YBF}+ol|f;2 zKa3e5VeIvDY(ze_c)DgEk=g1L?(sVY$QuMh!p^EA|9ajvR(#9_Nni~Ne(}1XDk=xq zX1*5&BF$S>0sAZ!hNn9@u+)2{+eA}BcMYXYRsK?rf)HAQUf)he!C$!IkF1fCGhWOr ztos^8yv+sniu=@5aYu}&|6>iQ{D^90x(F4LfExgAYHZ-Z``&q2O-*nGBNfIUU5MDa z{j8TId8i^^#*PWKq3{KDN2-Fc;5TMILdtZ)G?$JQJ{vo|;Y<##`${F4t(5+V5I8>O z0F*B82{ZwV8Sual7yvj64o_scC(yx;r*a!c2uEQurn#VHoiI9dm$!vjntpvS0jgS9 zphhqFMkg;8P89?#9uGPbYTc=Kz61aPmK9p6ubAvKyIb7T2)a9?>EWnmjP0 zwe7V~=YYb54#xmHmrS<04eD>i$9t;Gj1ba%#5~GX?3!+eptzdIq4e68Uo+?&TJZ+b*>L)afPD% zK?kyO?!bl46j;zSGK{9!Ozma+Oq%%1#R~CL=MHSJ_i*pYnTYO9@CM>L;{f=#(c-J+09c(3(A@U_$7OdC85(7XP5UC9&`-uXL#_l!)?Km>4p z+qa{_kQuOcs!QD;A5=+CeE4QXX@D~k?qS7M92#bYhYaf?6G+n>aT2rE>A@;4*Rb2z zc>ia8@zX7Jqos^&BOUPvFjI-fdR-5i@X$(+N0PoKcB=;p>y(8L-%_oz59|pPrMpph z^Sg7hYk19!89*%paMTR|>-0Un|70CaEaT6kd*TH^z9)bk43u=;lddV>3H@qLI7R#C!L}J%5p+m7kJ<* z7BaYA9@q!~dtrrLN}4JqqS~dhdl<^B_Y(nG*2)Chi^74FaKOU?r#1fg)O52t=9nwd zEmPkt5#W3mr6Y3#IM^$teUyVWLMf${T9qkOyF|N?l39mlTxv|377@M#RJ(H^B4s5X zsz});x9JTV{E^kA6Zc6n5cn>^UYunkUij$Bque`Y`-U4CeQ$+?xk5R3GH@_e@$(I( zgAlCxF~=)bwNQXjlSmSF{zgy;b|3py?NG=INy>~OW+^TZ&*zo$G&@=JPilE#*ud zGh*8Uu38dxkOJ4)5KN|=rzy#5u0eeWdaHy0=@)7@3o^aMZ6KgU%g0o zo+^@RPV%oO7XA0@ojRIy^Fc_yT1*H-TJ3hYvg zs-3Ce=LlTGNNeOelv6fU8J8}rneOPdEpytj_t3cz(5zV13HT_+?J&=)Q@F9Po2TQO z1{$pVeXSs68$HAPrhs0+GXjG58a)<0u8=O#_RTi}4P?^2>X!Q5)w#I6OD7c)1%WS# zG@{NQS8zyytpIG_p!^h3@88cOZ`z-xb*2j`cSzsaT>jh{@6a=^JUL#+4W#5wnW;W} zS$n8EEY1*oifo-ls*~B2qN>IxzPdxv0hc(nW2ia9QTY`|#BdLzE>IF@&feb+=hU|kMJ31+28k4OjR9CX0urpX^#&nI z1A!Bl9{CVuLGpetS+!qKr@T`Fbjg$b#Yh-caR~VL8aeP#sI#o8-$#7$bO$RuPZ>)< z7Zt1_1;PBPabpuchDNJsc{D~HXB1ai+!7lLm>czTEdA~(@3Nso-G4Eu0JMT6c&%`)xygw* zkS^0`O)M&)WoSdhBH|K*e415?B&Kp}Ypkbqjq=Y#YhJUoI)>YfR~LaB<|j&K`i&p` zD00x-Qye(#a?9vRc3$uGg(~PJSK%kx*DC$5>Iitne=--6-h;KZ1WJ>G0lbDUt49VuF?Wb-bBd6W1_N0Sg^y-#*V?X} z_G!$Fshn3Lh&)%_yHES?al5I!Xq>IZdEZ2x@6BQrGn4!D#KU*kEe7MiK^E2Y#?tHzibDJ ztc^eTmd*}GVrJGmL9uikB|Dhqa^_u~j^biW;Uc}=Uo#YK19XwefITgsvtn(rw#@bh z?A3nLdnr8Iy6pFwW}oM^K(@)0mn{nuXh|OGt-{5eK zWpHD6==SFGcm7I;heEH+t=eSA%XgRG;38r)W_0u#&kZ%j`mv*{P@&_pCV5Wdq z1Z)8l;ai6t*}T%Hj%Uv~EYE)k0xB=Pb~*(l&UJ*mnu6kQaMg8EXN}pp&AHHR@jvVr z)SmPq3(9aZ{6!yEdfJX-Z7twYdDAJ(VnaF|r8Xi1RT;E#dcsX?kR*a}Z9J?|ACZ~d zqiv=S=OmRSJGcbfEeSyKj@ZXR8OmYuH2r{;H@Do$fUp^goMl6SpB2G=I3z2W{Qxfy zaGUxD7FV}+7N*4G)sV}W)WeLS@Y0ibBFEZVZ$4foqll^KWk)67UBMFnam>*~;$#Mu zyiv4pK8KLJ%bGHtEb3@kl(Xw_+C&f)NjD z2R!8q8iB3XC?jHkZe4;l6`us!*dAsSvlduXY|w+0jdD2jcFnh887XZzcyo}67um{c zO+F#blc!%CK`iDaAyVw;;pkd{=DW%6Q*F{oAW}MCk*i|rU}w|MDXd6scHd@t%^aqa z5gi^4xqcXtgiIHMvL)EG(AuHGP-~Mge-5yB372u<|-EIoUQE1N7g$ZN>5s<1H@|ULVb;i;$b}v+RoUyH@-? z=S4b&@(hVz5*$Q`P* z(OV82kL>FL5^7x?r`qIPWp6UP+T23sfI_pzw zA)KVlEp(blu28TMJRA>$p-Lw`5>EQDoYmyPSoG&4av`Wn!m1nFgYbq~wvgI(Lhl z-*Eq8b0QC7;$Xb?-(;{%7X&sPU4Ih?p~ZV%s*0P39J; z+N?AGw&5LBWb`U}IG%l_DnpBL{KuP;Z^`12EW8zETEGq+#D8}c4@o_tyL=2+&V(8YNGpWt)jxFa{kuPZK zQ^{9cueUel6Ul|@(jTv=KwtKvD;G0 zcqQMjE`Dm2{#Ud%C62~VC?ZpMAS2^q#47w5bjyY#(%K&{VKrG1Hiin z0cTs`d3_T-?-s=0C>{wUEkx{iLBGu3FK0S0uY>^B8EE&oLlzp4@~*db7#M?aHo1Up zmQHN~K%hZkPw^%dDF7t!?^8uIvH(CC)IF_w8Ug`;VN(1nvA{pM)LUbs9bH?N~xaWB0 zo(5!%G!DfUnQsMxu)AjOH`=_twx8}Pa@VF+|>>qTBkHS5pQw0rXnGK@RnJeh@> zT&T4csQ4(xfN&oIQ9RQpQsl1yRNL7FEtx&FE5r(zg5R;|O323b*(iH&)w%-Wm&%!!;lm375di2s z5K=9%s$pfQsQddDy0)q}b;AkwTv3qT0bjwzLLZdz)dXsw{wJG~ zoM+By=qxkj<*igRlY!pPRrLg~D+|uiLq?h8fMH@9G29D+AKv@?x1ksdP;rt%zWhL7 zTIh4|*4AUzYmM3Iu@UBMAKTs^x#yS-vv{~lC;o1Hh@RX zD6eP*A)qT=DbZ)I`eJQno|nJ#S^rz8HnzpN<09l#k~L)(O$ii>sU~TPZF;=ymcO@%}4|X)`FVlp}IQp+!U;U-~tGwlXx@D<+iu6n?4QFwJ zL78BC{d@i!Q%%>V#T(iOevLe~0+zfCRFrx}mj`mjQj|Dl(T<|?O?Q4sLx5hn_ zPg3Co035F-h|v@6FE$jGRNVZ|Ot9j5+UZXv Date: Tue, 17 Feb 2026 22:00:37 +0200 Subject: [PATCH 043/218] Revert "resize screenshots" This reverts commit c8ab817932bfcaa2910c8e3e60e1c58b13c42b7b. --- screenshots/tool_main.png | Bin 44910 -> 37679 bytes screenshots/widget_telemetry_fullscren.png | Bin 36548 -> 31044 bytes screenshots/widget_vtxadmin_fullscreen.png | Bin 30282 -> 26645 bytes screenshots/widgets.png | Bin 36254 -> 22713 bytes 4 files changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshots/tool_main.png b/screenshots/tool_main.png index 3281e94c87e3340edfcf7e33fc9ed6284bcd8adb..41c01018154562e8460c69f01b00ed91b4d420b4 100644 GIT binary patch literal 37679 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�_wdu(b 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 c)a1@8J5kc2Gk{CnP256qn(A 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 z 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 diff --git a/screenshots/widget_telemetry_fullscren.png b/screenshots/widget_telemetry_fullscren.png index bf68bd769db84534b62dba5646380b2e84227f7a..3e2c8ed1a8d1822e8dedefcc3991f1b9a20910ad 100644 GIT binary patch literal 31044 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 literal 36548 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 diff --git a/screenshots/widget_vtxadmin_fullscreen.png b/screenshots/widget_vtxadmin_fullscreen.png index 09ceac413fae9884f794a7240909056ccd4299e8..923e4a62a436fb5ae92ca06928ed302503cf2e9e 100644 GIT binary patch literal 26645 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

OlLO0y%?eAM*>!iw2+}6 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 Za71X^iM8Rc+>6Z9IvR$z%hc{h{tp5VOlAN8 literal 30282 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 diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 04c26a9bbeb00d87beae22c652b4d7f826da759f..2c2ff9147e7b2c32f9ff7634848050b15a659b1b 100644 GIT binary patch literal 22713 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 From f0879aec64e283bbaaf9bdc9b563cfdfc653361a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 17 Feb 2026 22:02:29 +0200 Subject: [PATCH 044/218] update image widths --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f9353fd..ce9c1f6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. -![ExpressLRS Configuration Tool](screenshots/tool_main.png) +ExpressLRS Configuration Tool ### Architecture @@ -65,19 +65,19 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t Both widgets running side-by-side on the home screen: -![ELRS Widgets](screenshots/widgets.png) +ELRS Widgets ## ELRS Telemetry Widget The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). -![ELRS Telemetry Widget](screenshots/widget_telemetry_fullscren.png) +ELRS Telemetry Widget ## VTX Administrator Widget The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. -![VTX Administrator Widget](screenshots/widget_vtxadmin_fullscreen.png) +VTX Administrator Widget ## CRSF Simulator (Testing) From 93857d24ed107ba2a2c58f074ee5605b2e525970 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 09:48:48 +0200 Subject: [PATCH 045/218] Migrate to lvgl constants, appyl diff from philmoz --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 328 ++++++++++------------ src/WIDGETS/ELRSTelemetry/loadable.lua | 14 +- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 40 +-- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 34 +-- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 38 +-- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 40 +-- src/WIDGETS/ELRSTelemetry/ui/small.lua | 38 +-- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 6 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 16 +- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 40 +-- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 48 ++-- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 40 +-- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 42 +-- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 48 ++-- src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 4 +- 15 files changed, 369 insertions(+), 407 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index a6f71a8..a3995d1 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -65,25 +65,25 @@ function ModelMismatchDialog.show(onContinue, onExit) dg:build({ { - type = "box", + type = lvgl.BOX, x = 10, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_SMALL, children = { - { type = "label", text = "Receiver connected but Model ID doesn't match." }, - { type = "label", text = "This prevents controlling the wrong model." }, - { type = "label", text = "To use this receiver:" }, - { type = "label", text = "Set Model Match to OFF" }, + { type = lvgl.LABEL, text = "Receiver connected but Model ID doesn't match." }, + { type = lvgl.LABEL, text = "This prevents controlling the wrong model." }, + { type = lvgl.LABEL, text = "To use this receiver:" }, + { type = lvgl.LABEL, text = "Set Model Match to OFF" }, }, }, { - type = "box", + type = lvgl.BOX, w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_SMALL, children = { { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 48, text = "Continue", press = function() @@ -92,7 +92,7 @@ function ModelMismatchDialog.show(onContinue, onExit) end, }, { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 48, text = "Exit to Change Model", press = function() @@ -125,27 +125,27 @@ function NoModuleDialog.show(onExit) dg:build({ { - type = "box", + type = lvgl.BOX, x = 10, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_SMALL, children = { - { type = "label", text = "- Internal/External module enabled" }, - { type = "label", text = "- Protocol set to CRSF" }, - { type = "label", text = "- Minimum Baud rate (depends on packet rate):" }, - { type = "label", font = SMLSIZE, text = " 400k for 250Hz" }, - { type = "label", font = SMLSIZE, text = " 921k for 500Hz" }, - { type = "label", font = SMLSIZE, text = " 1.87M for F1000" }, + { type = lvgl.LABEL, text = "- Internal/External module enabled" }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, + { type = lvgl.LABEL, text = "- Minimum Baud rate (depends on packet rate):" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 400k for 250Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 921k for 500Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 1.87M for F1000" }, }, }, { - type = "box", + type = lvgl.BOX, w = lvgl.PERCENT_SIZE + 100, align = CENTER, flexFlow = lvgl.FLOW_ROW, children = { { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 98, text = "Exit", press = function() @@ -209,33 +209,33 @@ function CommandPage.showConfirm(name, info, onConfirm, onCancel) container:build({ { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_LARGE, thickness = 0, }, { - type = "label", + type = lvgl.LABEL, w = lvgl.PERCENT_SIZE + 100, align = CENTER, font = BOLD, text = name or "Command", }, { - type = "label", + type = lvgl.LABEL, w = lvgl.PERCENT_SIZE + 100, align = CENTER, color = COLOR_THEME_DISABLED, text = info or "", }, { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_LARGE, thickness = 0, }, { - type = "box", + type = lvgl.BOX, w = lvgl.PERCENT_SIZE + 100, align = CENTER, flexFlow = lvgl.FLOW_ROW, @@ -243,13 +243,13 @@ function CommandPage.showConfirm(name, info, onConfirm, onCancel) borderPad = lvgl.PAD_OUTLINE, children = { { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 49, text = "Confirm", press = onConfirm, }, { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 49, text = "Cancel", press = onCancel, @@ -279,7 +279,7 @@ function CommandPage.showExecuting(title, onCancel) container:build({ { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_LARGE, thickness = 0, @@ -288,26 +288,26 @@ function CommandPage.showExecuting(title, onCancel) createSpinner(container) container:build({ { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_LARGE, thickness = 0, }, { - type = "label", + type = lvgl.LABEL, w = lvgl.PERCENT_SIZE + 100, align = CENTER, color = COLOR_THEME_DISABLED, text = "Hold [RTN] to exit and keep running", }, { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_LARGE, thickness = 0, }, { - type = "box", + type = lvgl.BOX, w = lvgl.PERCENT_SIZE + 100, align = CENTER, flexFlow = lvgl.FLOW_ROW, @@ -315,7 +315,7 @@ function CommandPage.showExecuting(title, onCancel) borderPad = lvgl.PAD_OUTLINE, children = { { - type = "button", + type = lvgl.BUTTON, w = lvgl.PERCENT_SIZE + 100, text = "Cancel command", press = onCancel, @@ -708,141 +708,126 @@ end -- ============================================================================ local IS_NARROW = LCD_W < 400 -local LABEL_PCT = IS_NARROW and 42 or 50 -local CTRL_PCT = 100 - LABEL_PCT +local LABEL_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 42 or 50) -function UI.createChoiceRow(pg, field) - local row = pg:rectangle({ +function UI.createToggleRow(pg, field) + pg:setting({ 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, + title = field.name, children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - text = field.name or "", - color = COLOR_THEME_PRIMARY1 - } - } - }) - - local ctrlRect = row:rectangle({ - w = lvgl.PERCENT_SIZE + CTRL_PCT, - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - align = LEFT + VCENTER + { + type = lvgl.BOX, + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + children = { + { + type = lvgl.TOGGLE, + get = function() return field.value or 0 end, + set = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end, + }, + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = field.unit, + }, + }, + }, + }, + }, + }, }) +end - if UI.isBooleanField(field) then - ctrlRect:toggle({ - get = function() return field.value or 0 end, - set = function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end, - active = function() return not field.disabled end - }) - else - local filteredValues = {} - local origToFiltered = {} - local filteredToOrig = {} - for i, v in ipairs(field.values or {}) do - if v ~= "" then - filteredValues[#filteredValues + 1] = v - origToFiltered[i - 1] = #filteredValues - filteredToOrig[#filteredValues] = i - 1 - end +function UI.createChoiceRow(pg, field) + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, v in ipairs(field.values or {}) do + if v ~= "" then + filteredValues[#filteredValues + 1] = v + origToFiltered[i - 1] = #filteredValues + filteredToOrig[#filteredValues] = i - 1 end - ctrlRect:choice({ - values = filteredValues, - get = function() return origToFiltered[field.value or 0] or 1 end, - set = function(val) - field.value = filteredToOrig[val] or 0 - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end, - active = function() return not field.disabled end - }) end - if field.unit and field.unit ~= "" then - ctrlRect:box({ - h = lvgl.UI_ELEMENT_HEIGHT, - children = { + pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + children = { + { + type = lvgl.BOX, + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + children = { { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - text = table.concat({" ", field.unit}), - } - } - }) - end + type = lvgl.CHOICE, + values = filteredValues, + get = function() return origToFiltered[field.value or 0] or 1 end, + set = function(val) + field.value = filteredToOrig[val] or 0 + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() return not field.disabled end, + }, + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = field.unit, + }, + }, + }, + }, + }, + }, + }) end function UI.createNumberRow(pg, field) - local displayFn - if field.type == Protocol.CRSF.FLOAT then - displayFn = function(val) - return string.format(field.fmt or "%.0f", val / (field.prec or 1)) - end - else - displayFn = function(val) - return table.concat({tostring(val), field.unit or ""}) - end - end - pg:build({ { - type = "rectangle", + type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - thickness = 0, + title = field.name, children = { { - type = "rectangle", - w = lvgl.PERCENT_SIZE + LABEL_PCT, - thickness = 0, - children = { - { - type = "label", - color = COLOR_THEME_PRIMARY1, - text = field.name or "", - }, - }, - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + CTRL_PCT, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - thickness = 0, - children = { - { - type = "numberEdit", - min = field.min or 0, - max = field.max or 255, - get = function() return field.value or 0 end, - set = function(val) - field.value = val - end, - edited = function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadParentFolder(field) - end, - display = displayFn, - active = function() return not field.disabled end, - }, - }, + type = lvgl.NUMBER_EDIT, + x = LABEL_PCT, + min = field.min or 0, + max = field.max or 255, + get = function() return field.value or 0 end, + set = function(val) + field.value = val + end, + edited = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadParentFolder(field) + end, + display = function(val) + if field.type == Protocol.CRSF.FLOAT then + return string.format(field.fmt or "%.0f", val / (field.prec or 1)) + end + return table.concat({tostring(val), field.unit or ""}) + end, + active = function() return not field.disabled end, }, }, }, @@ -852,33 +837,14 @@ end function UI.createInfoRow(pg, field) pg:build({ { - type = "rectangle", + type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - thickness = 0, + title = field.name, children = { { - type = "rectangle", - w = lvgl.PERCENT_SIZE + LABEL_PCT, - thickness = 0, - children = { - { - type = "label", - color = COLOR_THEME_PRIMARY1, - text = field.name or "", - }, - }, - }, - { - type = "rectangle", - w = lvgl.PERCENT_SIZE + CTRL_PCT, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - thickness = 0, - children = { - { type = "label", text = field.value or "" }, - }, + type = lvgl.LABEL, + x = LABEL_PCT, + text = field.value, }, }, }, @@ -926,7 +892,11 @@ function UI.buildFieldWidget(pg, field, folderWidth) end if fieldType == Protocol.CRSF.TEXT_SELECTION then - return UI.createChoiceRow(pg, field) + if UI.isBooleanField(field) then + return UI.createToggleRow(pg, field) + else + return UI.createChoiceRow(pg, field) + end end if fieldType == Protocol.CRSF.STRING or fieldType == Protocol.CRSF.INFO then @@ -957,18 +927,10 @@ function UI.build() UI.currentPage = lvgl.page(pageOptions) - local outerContainer = UI.currentPage:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - align = CENTER - }) - - local fieldContainer = outerContainer:box({ + local fieldContainer = UI.currentPage:box({ w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - borderPad = lvgl.PAD_OUTLINE, + flexPad = lvgl.PAD_OUTLINE, }) local currentFolder = Navigation.getCurrent() diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index b69fd9a..f72a8df 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -194,7 +194,7 @@ local WidgetLayout = {} function WidgetLayout.column(w, h, opa, children) lvgl.build({ { - type = "rectangle", + type = lvgl.RECTANGLE, x = 0, y = 0, w = w, @@ -204,7 +204,7 @@ function WidgetLayout.column(w, h, opa, children) filled = true, }, { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -221,7 +221,7 @@ end function WidgetLayout.row(w, h, opa, children) lvgl.build({ { - type = "rectangle", + type = lvgl.RECTANGLE, x = 0, y = 0, w = w, @@ -231,7 +231,7 @@ function WidgetLayout.row(w, h, opa, children) filled = true, }, { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -305,13 +305,13 @@ end local function createSectionHeader(container, title) container:build({ { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0, }, { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_PRIMARY1, text = title, @@ -369,7 +369,7 @@ local function buildFullScreen() -- Model mismatch warning banner fields:build({ { - type = "label", + type = lvgl.LABEL, font = BOLD, color = RED, text = "Model Mismatch — RC commands not sent", diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 7e9bf5c..913e5a7 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -69,12 +69,12 @@ function WidgetUI.buildSixth(w, h, opa) local c3w = w - c1w - c2w local columns = { { - type = "box", + type = lvgl.BOX, w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, color = heroColorMismatch, @@ -83,12 +83,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, @@ -97,12 +97,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -120,7 +120,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -128,7 +128,7 @@ function WidgetUI.buildQuarter(w, h, opa) flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = BOLD, @@ -136,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = detailColor, @@ -145,7 +145,7 @@ function WidgetUI.buildQuarter(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT, flexFlow = lvgl.FLOW_ROW, @@ -153,7 +153,7 @@ function WidgetUI.buildQuarter(w, h, opa) flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -171,14 +171,14 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -190,14 +190,14 @@ function WidgetUI.buildThird(w, h, opa) text = heroTextLq, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -211,14 +211,14 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -230,21 +230,21 @@ function WidgetUI.buildFull(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_PRIMARY3, diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 6c7be6f..265427e 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -69,12 +69,12 @@ function WidgetUI.buildSixth(w, h, opa) local c2w = w - c1w local columns = { { - type = "box", + type = lvgl.BOX, w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, color = heroColorMismatch, @@ -83,12 +83,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, @@ -106,7 +106,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.35) local rows = { { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -114,7 +114,7 @@ function WidgetUI.buildQuarter(w, h, opa) borderPad = 0, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = BOLD, @@ -122,7 +122,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = detailColor, @@ -131,7 +131,7 @@ function WidgetUI.buildQuarter(w, h, opa) }, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -146,28 +146,28 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -181,14 +181,14 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -200,21 +200,21 @@ function WidgetUI.buildFull(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_PRIMARY3, diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 058070c..f86f8a3 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -69,12 +69,12 @@ function WidgetUI.buildSixth(w, h, opa) local c3w = w - c1w - c2w local columns = { { - type = "box", + type = lvgl.BOX, w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, color = heroColorMismatch, @@ -83,12 +83,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, @@ -97,12 +97,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -120,7 +120,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -128,7 +128,7 @@ function WidgetUI.buildQuarter(w, h, opa) borderPad = 0, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = BOLD, @@ -136,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = detailColor, @@ -145,7 +145,7 @@ function WidgetUI.buildQuarter(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT, flexFlow = lvgl.FLOW_ROW, @@ -153,7 +153,7 @@ function WidgetUI.buildQuarter(w, h, opa) borderPad = 0, children = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -170,21 +170,21 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- No title row on 480x272 — too tight rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -198,14 +198,14 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -217,21 +217,21 @@ function WidgetUI.buildFull(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_PRIMARY3, diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index e42088a..d52bbb1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -70,12 +70,12 @@ function WidgetUI.buildSixth(w, h, opa) local c3w = w - c1w - c2w local columns = { { - type = "box", + type = lvgl.BOX, w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, color = heroColorMismatch, @@ -84,12 +84,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, @@ -98,12 +98,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -121,14 +121,14 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = BOLD, @@ -136,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = detailColor, @@ -145,14 +145,14 @@ function WidgetUI.buildQuarter(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -170,14 +170,14 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row — 480x320 has more vertical room rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -189,14 +189,14 @@ function WidgetUI.buildThird(w, h, opa) text = heroTextLq, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -210,14 +210,14 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() if Telemetry.statusText() then @@ -229,21 +229,21 @@ function WidgetUI.buildFull(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_PRIMARY3, diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 5b10ca1..0366429 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -70,12 +70,12 @@ function WidgetUI.buildSixth(w, h, opa) local c3w = w - c1w - c2w local columns = { { - type = "box", + type = lvgl.BOX, w = c1w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, color = heroColorMismatch, @@ -84,12 +84,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c2w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = detailColor, @@ -98,12 +98,12 @@ function WidgetUI.buildSixth(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = c3w, h = lvgl.UI_ELEMENT_HEIGHT, children = { { - type = "label", + type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -124,14 +124,14 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.30) local rows = { { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = BOLD, @@ -139,7 +139,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = detailColor, @@ -148,14 +148,14 @@ function WidgetUI.buildQuarter(w, h, opa) }, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -172,21 +172,21 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- No title row — too tight on 320x240 rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, color = heroColorMismatch, text = heroTextLq, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, color = detailColor, text = Telemetry.signalText, } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -200,14 +200,14 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = function() return BOLD @@ -216,21 +216,21 @@ function WidgetUI.buildFull(w, h, opa) text = heroTextLq, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = detailColor, text = Telemetry.signalText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Telemetry.rfDetailText, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_PRIMARY3, diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 250247a..85fc389 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -20,7 +20,7 @@ end function TopBarUI.build(w, h) lvgl.build({ { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -30,7 +30,7 @@ function TopBarUI.build(w, h) flexPad = 0, children = { { - type = "label", + type = lvgl.LABEL, align = CENTER, font = SMLSIZE, color = function() @@ -51,7 +51,7 @@ function TopBarUI.build(w, h) end, }, { - type = "label", + type = lvgl.LABEL, align = CENTER, font = SMLSIZE, color = function() diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index dc1775b..a12e0f1 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -622,7 +622,7 @@ local WidgetLayout = {} function WidgetLayout.column(w, h, opa, children) lvgl.build({ { - type = "rectangle", + type = lvgl.RECTANGLE, x = 0, y = 0, w = w, @@ -632,7 +632,7 @@ function WidgetLayout.column(w, h, opa, children) filled = true, }, { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -649,7 +649,7 @@ end function WidgetLayout.row(w, h, opa, children) lvgl.build({ { - type = "rectangle", + type = lvgl.RECTANGLE, x = 0, y = 0, w = w, @@ -659,7 +659,7 @@ function WidgetLayout.row(w, h, opa, children) filled = true, }, { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -744,7 +744,7 @@ function VTXDisplay.build6posLabels() for i = 1, 6 do local idx = i labels[#labels + 1] = { - type = "label", font = SMLSIZE, + type = lvgl.LABEL, font = SMLSIZE, color = function() return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED end, @@ -763,7 +763,7 @@ function VTXDisplay.buildCheatsheet() local labels = VTXDisplay.build6posLabels() if #labels == 0 then return nil end return { - type = "box", + type = lvgl.BOX, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, @@ -905,13 +905,13 @@ end local function createSectionHeader(container, title) container:build({ { - type = "rectangle", + type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0, }, { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_PRIMARY1, text = title, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index aca6e05..d22c7a8 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -51,14 +51,14 @@ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = VTXDisplay.mainColor, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, w = c1w, font = WidgetUI.fonts.sixth.status, color = VTXDisplay.mainColor, @@ -66,7 +66,7 @@ function WidgetUI.buildSixth(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine, @@ -89,7 +89,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -97,7 +97,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -106,7 +106,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.status, @@ -114,7 +114,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.quarter.status, color = COLOR_THEME_SECONDARY1, @@ -137,13 +137,13 @@ function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -151,7 +151,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -160,7 +160,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.third.status, @@ -168,7 +168,7 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.status, color = COLOR_THEME_SECONDARY1, @@ -189,13 +189,13 @@ end function WidgetUI.buildHalf(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -203,7 +203,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.hero, color = VTXDisplay.mainColor, @@ -211,7 +211,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.detail, color = COLOR_THEME_SECONDARY1, @@ -230,13 +230,13 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -244,7 +244,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, color = VTXDisplay.mainColor, @@ -252,7 +252,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = COLOR_THEME_SECONDARY1, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 5557705..b59c52e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -72,7 +72,7 @@ local function buildCheatsheetNarrow() local vis = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end return { - type = "box", + type = lvgl.BOX, align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, @@ -81,7 +81,7 @@ local function buildCheatsheetNarrow() children = row1, }, { - type = "box", + type = lvgl.BOX, align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, @@ -106,7 +106,7 @@ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.28) local columns = { { - type = "label", + type = lvgl.LABEL, w = c1w, font = WidgetUI.fonts.sixth.status, color = VTXDisplay.mainColor, @@ -114,14 +114,14 @@ function WidgetUI.buildSixth(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, font = BOLD, color = VTXDisplay.mainColor, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = detailLine, @@ -142,13 +142,13 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.28) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -156,7 +156,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -165,7 +165,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.status, @@ -173,14 +173,14 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = pitModeColor, @@ -213,14 +213,14 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", } -- Loading state: full-width status label rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -229,7 +229,7 @@ function WidgetUI.buildThird(w, h, opa) } -- Active state: fixed-width band column + detail rows[#rows + 1] = { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -238,7 +238,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.third.status, @@ -246,7 +246,7 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -275,13 +275,13 @@ end function WidgetUI.buildHalf(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -289,7 +289,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.hero, color = VTXDisplay.mainColor, @@ -297,7 +297,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -334,13 +334,13 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -348,7 +348,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, color = VTXDisplay.mainColor, @@ -356,7 +356,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = COLOR_THEME_SECONDARY1, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index efd4e92..97e2efb 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -48,14 +48,14 @@ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = VTXDisplay.mainColor, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, w = c1w, font = WidgetUI.fonts.sixth.status, color = VTXDisplay.mainColor, @@ -63,7 +63,7 @@ function WidgetUI.buildSixth(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine, @@ -86,7 +86,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -94,7 +94,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -103,7 +103,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.status, @@ -111,7 +111,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.quarter.status, color = COLOR_THEME_SECONDARY1, @@ -134,13 +134,13 @@ function WidgetUI.buildThird(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -148,7 +148,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -157,7 +157,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.third.status, @@ -165,7 +165,7 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -186,13 +186,13 @@ end function WidgetUI.buildHalf(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -200,7 +200,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.hero, color = VTXDisplay.mainColor, @@ -208,7 +208,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -227,13 +227,13 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -241,7 +241,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, color = VTXDisplay.mainColor, @@ -249,7 +249,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = COLOR_THEME_SECONDARY1, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 31cfcf6..c729d1e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -63,14 +63,14 @@ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = VTXDisplay.mainColor, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, w = c1w, font = WidgetUI.fonts.sixth.status, color = VTXDisplay.mainColor, @@ -78,7 +78,7 @@ function WidgetUI.buildSixth(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.detailLine, @@ -102,7 +102,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local row1 = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.status, @@ -110,7 +110,7 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.quarter.status, color = COLOR_THEME_SECONDARY1, @@ -119,7 +119,7 @@ function WidgetUI.buildQuarter(w, h, opa) } if wide then row1[#row1 + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = pitModeColor, @@ -128,7 +128,7 @@ function WidgetUI.buildQuarter(w, h, opa) end local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -136,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -162,13 +162,13 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Title row — 480x320 has more vertical room than 480x272 rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", } rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -176,7 +176,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showStatus, } rows[#rows + 1] = { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -185,7 +185,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.third.status, @@ -193,7 +193,7 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -213,13 +213,13 @@ end function WidgetUI.buildHalf(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -227,7 +227,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.hero, color = VTXDisplay.mainColor, @@ -235,7 +235,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -254,13 +254,13 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -268,7 +268,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, color = VTXDisplay.mainColor, @@ -276,7 +276,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = COLOR_THEME_SECONDARY1, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 98582b4..66e020a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -71,14 +71,14 @@ function WidgetUI.buildSixth(w, h, opa) local c1w = math.floor(w * 0.22) local columns = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = VTXDisplay.mainColor, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, w = c1w, font = WidgetUI.fonts.sixth.status, color = VTXDisplay.mainColor, @@ -86,14 +86,14 @@ function WidgetUI.buildSixth(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = pitModeColor, @@ -115,7 +115,7 @@ function WidgetUI.buildQuarter(w, h, opa) local c1w = math.floor(w * 0.22) local rows = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -123,7 +123,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -132,7 +132,7 @@ function WidgetUI.buildQuarter(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.status, @@ -140,14 +140,14 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = RED, @@ -176,7 +176,7 @@ function WidgetUI.buildThird(w, h, opa) local rows = {} -- Loading state: full-width status label rows[#rows + 1] = { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -185,7 +185,7 @@ function WidgetUI.buildThird(w, h, opa) } -- Active state: band + power + pit mode row (no title — too tight on 320x240) rows[#rows + 1] = { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -194,7 +194,7 @@ function WidgetUI.buildThird(w, h, opa) visible = VTXDisplay.showChannel, children = { { - type = "label", + type = lvgl.LABEL, w = c1w, align = LEFT, font = WidgetUI.fonts.third.status, @@ -202,14 +202,14 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.bandChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = pitModeColor, @@ -229,13 +229,13 @@ end function WidgetUI.buildHalf(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -243,7 +243,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.half.hero, color = VTXDisplay.mainColor, @@ -251,7 +251,7 @@ function WidgetUI.buildHalf(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "box", + type = lvgl.BOX, w = w, align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, @@ -259,14 +259,14 @@ function WidgetUI.buildHalf(w, h, opa) borderPad = 0, children = { { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = pitModeColor, @@ -287,13 +287,13 @@ end function WidgetUI.buildFull(w, h, opa) local rows = { { - type = "label", + type = lvgl.LABEL, font = BOLD, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = BOLD, color = VTXDisplay.mainColor, @@ -301,7 +301,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showStatus, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, color = VTXDisplay.mainColor, @@ -309,7 +309,7 @@ function WidgetUI.buildFull(w, h, opa) visible = VTXDisplay.showChannel, }, { - type = "label", + type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, color = COLOR_THEME_SECONDARY1, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua index b8b236b..5b951a5 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -23,7 +23,7 @@ end function TopBarUI.build(w, h) lvgl.build({ { - type = "box", + type = lvgl.BOX, x = 0, y = 0, w = w, @@ -33,7 +33,7 @@ function TopBarUI.build(w, h) flexPad = lvgl.PAD_TINY, children = { { - type = "label", + type = lvgl.LABEL, align = CENTER, font = MIDSIZE, color = COLOR_THEME_PRIMARY2, From f109fd0f405efef962e1b8aaace756d8ab42ae19 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 11:09:49 +0200 Subject: [PATCH 046/218] fix code style issue --- src/SCRIPTS/ELRS/crsf.lua | 4 +- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 12 ++--- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 6 ++- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 49 ++++++++++++++++---- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 18 ++++--- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 2 - test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 10 ++-- 11 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index a524dd1..773edb0 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -140,7 +140,9 @@ local function setMock() local mock = mockModule() CRSF.pop = mock.pop CRSF.push = mock.push - CRSF.hasCrsfModule = function() return mock.moduleFound end + CRSF.hasCrsfModule = function() + return mock.moduleFound + end CRSF.getSensorValue = mock.getSensorValue end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 2c08d98..fbdc5ef 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -332,8 +332,6 @@ function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) local r = last or (isOpts and {}) local optParts = {} local vcnt = 0 - local charUp = CHAR_UP or (__opentx and __opentx.CHAR_UP) - local charDown = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) repeat local b = data[offset] offset = offset + 1 @@ -346,12 +344,12 @@ function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) optParts = {} end elseif b ~= 0 then - -- Translate legacy OpenTX arrow bytes (0xC0/0xC1) from ELRS firmware + -- Translate legacy arrow bytes (0xC0/0xC1) from ELRS firmware -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs - if b == 192 and charUp then - optParts[#optParts + 1] = charUp - elseif b == 193 and charDown then - optParts[#optParts + 1] = charDown + if b == 192 and CHAR_UP then + optParts[#optParts + 1] = CHAR_UP + elseif b == 193 and CHAR_DOWN then + optParts[#optParts + 1] = CHAR_DOWN else optParts[#optParts + 1] = string.char(b) end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index d8dc906..061b100 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -298,7 +298,7 @@ function UI.incrField(step) field.value = newval return end - until (newval == min or newval == max) + until newval == min or newval == max end -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index a3995d1..c4fefc1 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -456,7 +456,9 @@ end function UI.handleNoModule() if not UI.uiBuilt then - NoModuleDialog.show(function() App.shouldExit = true end) + NoModuleDialog.show(function() + App.shouldExit = true + end) UI.uiBuilt = true end end @@ -693,7 +695,7 @@ function UI.incrField(field, step) field.value = newval return end - until (newval == min or newval == max) + until newval == min or newval == max end function UI.isBooleanField(field) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index a12e0f1..0a7dcf5 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -59,7 +59,11 @@ function VTX.parseFolderName(name) local s = VTX.state local content = string.match(name, "%((.+)%)") if not content then - s.band = 0; s.bandLetter = "Off"; s.channel = 0; s.power = 0; s.pitmode = false + s.band = 0 + s.bandLetter = "Off" + s.channel = 0 + s.power = 0 + s.pitmode = false return true end @@ -125,9 +129,17 @@ Protocol = { } -- State query helpers -function Protocol.isReady() return Protocol.state == Protocol.STATE_READY end -function Protocol.isSending() return Protocol.state == Protocol.STATE_SENDING end -function Protocol.isActive() return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING end +function Protocol.isReady() + return Protocol.state == Protocol.STATE_READY +end + +function Protocol.isSending() + return Protocol.state == Protocol.STATE_SENDING +end + +function Protocol.isActive() + return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING +end -- Response timeout for PARAMETER_READ: 0.5s for local TX module. function Protocol.fieldResponseTimeout() @@ -968,22 +980,36 @@ local function buildFullScreen() createChoiceRow(fields, "Band", { "Off", "A", "B", "E", "F", "R", "L" }, function() return d.band + 1 end, - function(idx) d.band = idx - 1; Protocol.writeConfig() end) + function(idx) + d.band = idx - 1 + Protocol.writeConfig() + end) createNumberRow(fields, "Channel", 1, 8, function() return d.channel end, function(v) d.channel = v end, - function(v) d.channel = v; Protocol.writeConfig() end) + function(v) + d.channel = v + Protocol.writeConfig() + end) createNumberRow(fields, "Power Level", 0, 8, function() return d.power end, function(v) d.power = v end, - function(v) d.power = v; Protocol.writeConfig() end, - function(v) return v == 0 and "-" or tostring(v) end) + function(v) + d.power = v + Protocol.writeConfig() + end, + function(v) + return v == 0 and "-" or tostring(v) + end) createToggleRow(fields, "Pit Mode", function() return d.pitmode end, - function(v) d.pitmode = v; Protocol.writeConfig() end) + function(v) + d.pitmode = v + Protocol.writeConfig() + end) fields:button({ text = function() @@ -993,7 +1019,10 @@ local function buildFullScreen() return "Send VTx" end, w = lvgl.PERCENT_SIZE + 100, - press = function() Protocol.writeConfig(); Protocol.pushToVtx() end, + press = function() + Protocol.writeConfig() + Protocol.pushToVtx() + end, active = function() return Protocol.isReady() end, }) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index d22c7a8..08a1b41 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -6,8 +6,6 @@ local ctx = ... local VTX = ctx.VTX local Protocol = ctx.Protocol -local Presets = ctx.Presets -local crsf = ctx.crsf local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index b59c52e..0839c5c 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -6,8 +6,6 @@ local ctx = ... local VTX = ctx.VTX local Protocol = ctx.Protocol -local Presets = ctx.Presets -local crsf = ctx.crsf local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout @@ -67,9 +65,15 @@ local function buildCheatsheetNarrow() return nil, nil end local row1, row2 = {}, {} - for i = 1, 3 do row1[#row1 + 1] = labels[i] end - for i = 4, 6 do row2[#row2 + 1] = labels[i] end - local vis = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end + for i = 1, 3 do + row1[#row1 + 1] = labels[i] + end + for i = 4, 6 do + row2[#row2 + 1] = labels[i] + end + local hasModule = function() + return Protocol.state ~= Protocol.STATE_NO_MODULE + end return { type = lvgl.BOX, @@ -77,7 +81,7 @@ local function buildCheatsheetNarrow() flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, borderPad = 0, - visible = vis, + visible = hasModule, children = row1, }, { @@ -86,7 +90,7 @@ local function buildCheatsheetNarrow() flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, borderPad = 0, - visible = vis, + visible = hasModule, children = row2, } end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 97e2efb..8b68f91 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -6,8 +6,6 @@ local ctx = ... local VTX = ctx.VTX local Protocol = ctx.Protocol -local Presets = ctx.Presets -local crsf = ctx.crsf local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index c729d1e..f4fc0e6 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -6,8 +6,6 @@ local ctx = ... local VTX = ctx.VTX local Protocol = ctx.Protocol -local Presets = ctx.Presets -local crsf = ctx.crsf local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 66e020a..f26b756 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -6,8 +6,6 @@ local ctx = ... local VTX = ctx.VTX local Protocol = ctx.Protocol -local Presets = ctx.Presets -local crsf = ctx.crsf local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index b1f2cec..0ce21ea 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -239,7 +239,7 @@ end -- @param chunk chunk index (0 for single-chunk params) -- @param destAddr destination address -- @return data table suitable for queuePush(FRAMETYPE_PARAMETER_SETTINGS_ENTRY, data) -local function encodeParameterEntry(device, param, chunk, destAddr) +local function encodeParameterEntry(device, param, _chunk, destAddr) local data = {} data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER data[2] = device.id @@ -299,12 +299,8 @@ local function encodeParameterEntry(device, param, chunk, destAddr) end data[#data + 1] = 0xFF -- terminator - elseif t == CRSF.INFO then - -- Info value string (null-terminated) - appendString(data, param.value or "") - - elseif t == CRSF.STRING then - -- String value (null-terminated) + elseif t == CRSF.INFO or t == CRSF.STRING then + -- INFO (read-only) and STRING (editable) both encode as null-terminated string appendString(data, param.value or "") elseif t == CRSF.UINT8 then From b3b9719b934aa0b41e27c57de19639609e3201b2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 11:12:13 +0200 Subject: [PATCH 047/218] drop opentx support --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 061b100..f55a9d1 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -10,9 +10,6 @@ local Navigation = deps.Navigation local Protocol = deps.Protocol local VERSION = deps.VERSION --- Popup compatibility wrapper (set in UI.init) -local popupCompat - -- ============================================================================ -- UI state -- ============================================================================ @@ -63,16 +60,6 @@ function UI.init() UI.COL1 = 0 UI.textYoffset = 3 UI.textSize = 8 - - -- Determine popupConfirmation argument count - local _, _, major = getVersion() - if major ~= 1 then - popupCompat = popupConfirmation - else - popupCompat = function(t, m, e) - return popupConfirmation(t, e) - end - end end -- ============================================================================ @@ -540,11 +527,11 @@ function UI.drawPopup(event) end if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then - popupCompat(Protocol.fieldPopup.info or "", "Stopped!", event) + popupConfirmation(Protocol.fieldPopup.info or "", "Stopped!", event) Protocol.reloadAllFields() Protocol.fieldPopup = nil elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then - local result = popupCompat(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) + local result = popupConfirmation(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "OK" then Protocol.commandConfirm() @@ -555,7 +542,7 @@ function UI.drawPopup(event) if Protocol.fieldChunk == 0 then UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end - local result = popupCompat( + local result = popupConfirmation( (Protocol.fieldPopup.info or "") .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", "Press [RTN] to exit", event) From 34ad8fdf347379c3490411fb943dea52b8d56840 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 11:44:02 +0200 Subject: [PATCH 048/218] update lint --- src/SCRIPTS/ELRS/crsf.lua | 2 ++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 4 ++-- src/WIDGETS/ELRSTelemetry/loadable.lua | 2 +- src/WIDGETS/ELRSTelemetry/main.lua | 1 + src/WIDGETS/ELRSVTXAdmin/loadable.lua | 3 ++- src/WIDGETS/ELRSVTXAdmin/main.lua | 1 + 7 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 773edb0..263a0cf 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -281,6 +281,7 @@ local function onDeviceInfo(data) -- RFMOD / RFRSSI lookup tables (version-dependent) if info.vMaj == 4 then + -- selene: allow(mixed_table) info.RFMOD = { "25Hz", "50Hz", "100Hz", "100HzFull", "150Hz", "200Hz", "200HzFull", "250Hz", "333HzFull", "500Hz", "D50", "K1000Full", @@ -291,6 +292,7 @@ local function onDeviceInfo(data) [35]="DK250", [36]="DK500", [37]="K1000", [101]="X100Full", [102]="X150", } + -- selene: allow(mixed_table) info.RFRSSI = { -123, -120, -117, -112, 0, -112, -111, -111, 0, 0, -112, -101, [21]=0, [22]=-115, [23]=0, [24]=-112, [25]=-112, [26]=0, [27]=0, diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index f55a9d1..133dbf0 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -119,7 +119,7 @@ end -- Interface: render -- ============================================================================ -function UI.render(event, touchState) +function UI.render(event, _touchState) -- Warning flashing timer local time = getTime() if time > UI.titleShowWarnTimeout then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index c4fefc1..eecf56d 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -334,7 +334,7 @@ end local versionCheckResult = nil local function checkEdgeTxVersion() - local ver, radio, maj, minor, rev = getVersion() + local ver, _radio, maj, minor, rev = getVersion() if maj >= 3 then return true @@ -608,7 +608,7 @@ end -- Interface: render -- ============================================================================ -function UI.render(event, touchState) +function UI.render(_event, _touchState) handleCommandPopup() if not UI.commandDialog then diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index f72a8df..b66c987 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -572,7 +572,7 @@ function wgt.background() Telemetry.updateGps() end -function wgt.refresh(event, touchState) +function wgt.refresh(_event, _touchState) wgt.background() -- Update diversity detection each tick diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index a2c1503..fca8c9e 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -9,6 +9,7 @@ local name = "ELRSTelemetry" +-- selene: allow(undefined_variable) local function create(zone, options) if not _crsfSingleton then local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 0a7dcf5..7f7832b 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -22,6 +22,7 @@ local Presets VTX = { -- Band name lookup tables + -- selene: allow(mixed_table) BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, @@ -1108,7 +1109,7 @@ function wgt.background() Presets.processPushSource() end -function wgt.refresh(event, touchState) +function wgt.refresh(_event, _touchState) wgt.background() end diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index f6701b9..e6982d0 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -9,6 +9,7 @@ local name = "ELRSVTXAdmin" +-- selene: allow(undefined_variable) local function create(zone, options) if not _crsfSingleton then local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") From a46d011cc128df199d291d75b2662153419d8c5b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 15:13:47 +0200 Subject: [PATCH 049/218] Add BW screenshot --- README.md | 2 ++ screenshots/tool_main_bw.png | Bin 0 -> 2099 bytes 2 files changed, 2 insertions(+) create mode 100644 screenshots/tool_main_bw.png diff --git a/README.md b/README.md index ce9c1f6..ec8f32a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. +ExpressLRS Configuration Tool
+ ExpressLRS Configuration Tool ### Architecture diff --git a/screenshots/tool_main_bw.png b/screenshots/tool_main_bw.png new file mode 100644 index 0000000000000000000000000000000000000000..b87dccfb74b12bd90114f6420d4588bcacadaa1c GIT binary patch literal 2099 zcmX9=Yg7~07Ct0c0TqjnN>Q-a;-gZ;7sw^@&;kKtMJNyfgSwDsgx-qbkr)zGL=mKF zBiBbTEmExHA%uXE1WgxZcYGzC6N)KF#y=vJ^|a|6WjYwNedQm{8vOmE&$HIV~-uj%Xu{b3&$cu_rw<5 z)(?}e4*4x+AN%(H(x0+VS{qy16vvg6)EA2#J_Tz(wAa?HkIqUs7lA`*6g9Ho=%U~v zR3_B5TF|+*RVX^vxC$iE<##q+MV1rkxIz>yIy{lvt0fL*qB05d151uvM2c#yKE2wS zc9xz=vz}tW{hk4^``(xA7O46NiQd@lk=oKmZ4*G> z-Of5y{#xJE;JyynECM1|SX`Smim>+#Q7$JWl} z0lL%2mMcxhS*F+-bet_*9HI+yD`Ksagd^lz3o_g*53|>3=0ZucP`I)0o^gae;$jC% zn3R4zXx%#j=Sy%;ux%6w$bDYgV3q zw@5K{&yAy+#mPsc>cZt1*fVfcZ|7FG`S@WaN&nvi?R^Pe^MHw(^cc$l%%OHaw%El@#TlNf5z9 z2x}Kz&aZhae&!ip&#mH?46MGfFJ*zZ#&pGEja~{mO8pnTS|;EAH8rSzGXsjh%5;wx zl#4rQuTWZPggj`Txhw=q0W~JG zhE)YK>f9?hnq%2@r#?b?;UfHl%d}8I?b-@^zIu?5&ZC{y(G2c8zE}mo%Hmh0`Gy^s zrZn;bxbx1D^`so7%tC2&6g>JO*=wsHB_7h7+(d;{^eV%=);!Dn_QzPQdQ^+26>F?LE@+*JuHbboC< z?*KNv<0!@4_x8rF$tT7GGnS}tY@N12XupPAs_tenMBn__+z0QE`D`?UAkinKQbI1# zAQobR{+^`C$1(_&Z{`(LZs@tm9H%%o)0yC+r2DZ!D_?}^)#KVR5!D~pxJvL`ae3SE z8ysu!oGfb3JO}tsX8O4S;_Fs_S{L`DN_qm zyuGp5yzIMj6E+kPsL}WrzqJOR7jNjtThk`g&9-f^<~I;B7k9LNB1Im?Ws)7>q63-(UYj>QrM^4H+4kEmQG+8!Q$>#J zRa6hXl)Kkejy7kAwR90Vw)-rc8zUw5e79YCQ>6auq7BsJ5l}=msjhvnAQ>t;UVxUj zr7*${DgqxO2yGwC^}}VNQaH#JWsz(es6NtOzl-S5`K!u&_}>%R zWLo+9eJ~T#3cdgutH-0MLNR@xZd~H2;}*atx+d;6Y|+EfSS>b@#0*QHRxdWamiXc% zzE-|3j?pp_XXIbXuk5xtpEl})=Y~t2e{Km7(5%t)^n|-+Cxy0;Fc~;250ebKxum)u z*=yqp546oKR`LTn(D*9yqs{n4z0$C8mg3LYAD(!rNE??Mb&Fwl1oJ<~rhb}o=^>i? zlSxi0e+EI`Mx<#6$k-@fPhJ{A Date: Wed, 18 Feb 2026 13:23:33 +0200 Subject: [PATCH 050/218] Add lua-language-server, stylua, add edgetx lua api type definitions --- .gitignore | 1 + .luarc.json | 16 + Makefile | 38 + src/WIDGETS/ELRSTelemetry/loadable.lua | 1 + src/WIDGETS/ELRSTelemetry/main.lua | 2 + src/WIDGETS/ELRSVTXAdmin/main.lua | 2 + test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 4 + types/audio.d.lua | 56 ++ types/bit32.d.lua | 67 ++ types/bitmap.d.lua | 67 ++ types/dir.d.lua | 58 ++ types/edgetx.d.lua | 396 +++++++++ types/hardware.d.lua | 43 + types/lcd.d.lua | 528 ++++++++++++ types/lua-runtime.d.lua | 94 ++ types/lvgl.d.lua | 848 +++++++++++++++++++ types/model.d.lua | 352 ++++++++ types/runtime.d.lua | 22 + types/serial.d.lua | 28 + types/telemetry.d.lua | 180 ++++ types/time.d.lua | 19 + types/widget.d.lua | 43 + 22 files changed, 2865 insertions(+) create mode 100644 .luarc.json create mode 100644 Makefile create mode 100644 types/audio.d.lua create mode 100644 types/bit32.d.lua create mode 100644 types/bitmap.d.lua create mode 100644 types/dir.d.lua create mode 100644 types/edgetx.d.lua create mode 100644 types/hardware.d.lua create mode 100644 types/lcd.d.lua create mode 100644 types/lua-runtime.d.lua create mode 100644 types/lvgl.d.lua create mode 100644 types/model.d.lua create mode 100644 types/runtime.d.lua create mode 100644 types/serial.d.lua create mode 100644 types/telemetry.d.lua create mode 100644 types/time.d.lua create mode 100644 types/widget.d.lua diff --git a/.gitignore b/.gitignore index 95520e1..deb2d87 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.luac color/WIDGETS/ELRSVTXAdmin/presets.txt +bin/ \ No newline at end of file diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..9d8300f --- /dev/null +++ b/.luarc.json @@ -0,0 +1,16 @@ +{ + "runtime.version": "Lua 5.3", + "runtime.builtin": { + "os": "disable", + "debug": "disable", + "package": "disable", + "coroutine": "disable", + "utf8": "disable", + "io": "disable", + "bit32": "disable" + }, + "workspace.library": ["types"], + "workspace.ignoreDir": ["bin", "types"], + "workspace.checkThirdParty": false, + "diagnostics.disable": ["lowercase-global"] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3aa24cf --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +SRC_DIR := src +TEST_DIR := test + +LUA_DIRS := $(SRC_DIR) $(TEST_DIR) + +LUALS_VERSION := 3.17.1 +LUALS_DIR := bin/lua-language-server +LUALS := $(LUALS_DIR)/bin/lua-language-server +LUALS_URL := https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-linux-x64.tar.gz + +.PHONY: help install-tools format format-check typecheck check + +help: + @echo "Usage: make " + @echo "" + @echo " install-tools Install stylua (via cargo) and lua-language-server" + @echo " format Format Lua files with stylua" + @echo " format-check Check formatting without modifying files" + @echo " typecheck Run lua-language-server type checking" + @echo " check Run format-check and typecheck" + +install-tools: + ec + @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } + cargo install stylua --features lua53 + mkdir -p $(LUALS_DIR) + curl -fSL $(LUALS_URL) | tar xz -C $(LUALS_DIR) + +format: + stylua $(LUA_DIRS) + +format-check: + stylua --check $(LUA_DIRS) + +typecheck: + $(LUALS) --check . + +check: format-check typecheck diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index b66c987..60629ce 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -30,6 +30,7 @@ Telemetry = { isDiversity = false, -- Cached GPS position (persists across disconnects) + ---@type {lat: number, lon: number}|nil gps = nil, -- Power level mapping table diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index fca8c9e..4223942 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -13,9 +13,11 @@ local name = "ELRSTelemetry" local function create(zone, options) if not _crsfSingleton then local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") + ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + ---@diagnostic disable-next-line: need-check-nil return loadable(zone, options, _crsfSingleton) end diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index e6982d0..b5749fd 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -13,9 +13,11 @@ local name = "ELRSVTXAdmin" local function create(zone, options) if not _crsfSingleton then local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") + ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + ---@diagnostic disable-next-line: need-check-nil return loadable(zone, options, _crsfSingleton) end diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 0ce21ea..7b604a3 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -171,6 +171,7 @@ local function queuePop() -- Serve deferred packets only after a nil has been returned (next poll cycle) if deferredReady and #deferredQueue > 0 then local pkt = tableRemove(deferredQueue, 1) + ---@diagnostic disable-next-line: need-check-nil return pkt.command, pkt.data end @@ -599,6 +600,7 @@ local function updateFolderNames(device) local pwrVal = (vtxPwr and vtxPwr.value) or 0 if pwrVal > 0 then + ---@diagnostic disable-next-line: need-check-nil local pwrLabel = getOptionLabel(vtxPwr.options, pwrVal) name = name .. ":" .. pwrLabel @@ -606,6 +608,7 @@ local function updateFolderNames(device) if pitVal == 1 then name = name .. ":P" elseif pitVal > 1 then + ---@diagnostic disable-next-line: need-check-nil local pitLabel = getOptionLabel(vtxPit.options, pitVal) name = name .. ":" .. pitLabel end @@ -946,6 +949,7 @@ local function mockPop() while i <= #delayedResponseQueue do if now >= delayedResponseQueue[i].deliverAt then local entry = tableRemove(delayedResponseQueue, i) + ---@diagnostic disable-next-line: need-check-nil queuePush(entry.command, entry.data) else i = i + 1 diff --git a/types/audio.d.lua b/types/audio.d.lua new file mode 100644 index 0000000..fec57cd --- /dev/null +++ b/types/audio.d.lua @@ -0,0 +1,56 @@ +---@meta + +-- EdgeTX audio and haptic playback functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Play an audio file from the SD card. +--- @since 2.0.0 +---@param path string Audio file path (e.g. "/SOUNDS/en/hello.wav") +---@param volume? integer Volume override (1-5, omit to use system setting) +function playFile(path, volume) end + +--- Play a number with optional unit announcement. +--- @since 2.0.0 +---@param value integer Number to play +---@param unit integer Unit constant (UNIT_*) +---@param attributes? integer +---@param volume? integer Volume override (1-5, omit to use system setting) +function playNumber(value, unit, attributes, volume) end + +--- Play a duration value (hours, minutes, seconds). +--- @since 2.0.0 +---@param duration integer Seconds +---@param playTime? integer Non-zero to announce as time of day +---@param volume? integer Volume override (1-5, omit to use system setting) +function playDuration(duration, playTime, volume) end + +--- Play a tone with configurable frequency, duration, and pattern. +--- @since 2.1.0 +---@param frequency integer Hz +---@param duration integer ms +---@param pause integer ms of silence after tone +---@param flags? integer 0 or PLAY_NOW +---@param freqIncr? integer Frequency increment per repeat +---@param volume? integer Volume override (1-5, omit to use system setting) +function playTone(frequency, duration, pause, flags, freqIncr, volume) end + +--- Generate haptic feedback. +--- @since 2.2.0 +---@param duration integer ms +---@param pause? integer ms of silence after haptic +---@param flags? integer 0 or PLAY_NOW +function playHaptic(duration, pause, flags) end + +--- Flush the audio queue, stopping all current and pending audio playback. +function flushAudio() end + +-- ========================================================================== +-- Audio constants +-- ========================================================================== + +---@type integer +PLAY_NOW = 0 +---@type integer +PLAY_BACKGROUND = 0 +---@type integer +TIMEHOUR = 0 diff --git a/types/bit32.d.lua b/types/bit32.d.lua new file mode 100644 index 0000000..4ce0156 --- /dev/null +++ b/types/bit32.d.lua @@ -0,0 +1,67 @@ +---@meta + +-- Lua 5.2 bit32 library (available in EdgeTX runtime) +-- Custom definition to avoid deprecation warnings from lua-language-server. +-- The built-in bit32 is disabled in .luarc.json because LuaLS marks it +-- deprecated under Lua 5.3, but EdgeTX's runtime still requires it. + +---@class bit32lib +bit32 = {} + +---@param ... integer +---@return integer +function bit32.band(...) end + +---@param ... integer +---@return integer +function bit32.bor(...) end + +---@param ... integer +---@return integer +function bit32.bxor(...) end + +---@param x integer +---@return integer +function bit32.bnot(x) end + +---@param ... integer +---@return boolean +function bit32.btest(...) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.lshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.rshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.arshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.lrotate(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.rrotate(x, disp) end + +---@param n integer +---@param field integer +---@param width? integer +---@return integer +function bit32.extract(n, field, width) end + +---@param n integer +---@param v integer +---@param field integer +---@param width? integer +---@return integer +function bit32.replace(n, v, field, width) end diff --git a/types/bitmap.d.lua b/types/bitmap.d.lua new file mode 100644 index 0000000..628a965 --- /dev/null +++ b/types/bitmap.d.lua @@ -0,0 +1,67 @@ +---@meta + +-- EdgeTX bitmap module (color LCD only) +-- Source: edgetx/radio/src/lua/api_colorlcd.cpp + +---@class BitmapObject + +---@class bitmap +bitmap = {} + +--- Load a bitmap from the SD card. Bitmaps should be loaded only once; store the +--- returned object for later drawing with lcd.drawBitmap(). If loading fails the +--- resulting bitmap object will have width and height of zero. +--- Loading can fail if the file is not found, the image is invalid, +--- the system is low on memory, or combined Lua bitmap usage exceeds the limit. +--- @since 2.2.0 +---@param path string Full path to the bitmap on SD card (e.g. "/IMAGES/test.bmp") +---@return BitmapObject bmp +function bitmap.open(path) end + +--- Return width and height of a bitmap object. +--- @since 2.2.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@return integer width Width in pixels +---@return integer height Height in pixels +function bitmap.getSize(bmp) end + +--- Return a new resized copy of a bitmap. +--- @since 2.8.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@param width integer New width in pixels +---@param height integer New height in pixels +---@return BitmapObject|nil resized nil if input bitmap is invalid +function bitmap.resize(bmp, width, height) end + +--- Convert a bitmap to an 8-bit mask for use with lcd.drawBitmapPattern(). +--- @since 2.8.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@return string mask Binary mask data +function bitmap.toMask(bmp) end + +--- @deprecated Use `bitmap` module instead. +---@class Bitmap +Bitmap = {} + +--- @deprecated Use `bitmap.open` instead. +---@param path string Full path to the bitmap on SD card +---@return BitmapObject bmp +function Bitmap.open(path) end + +--- @deprecated Use `bitmap.getSize` instead. +---@param bmp BitmapObject +---@return integer width +---@return integer height +function Bitmap.getSize(bmp) end + +--- @deprecated Use `bitmap.resize` instead. +---@param bmp BitmapObject +---@param width integer +---@param height integer +---@return BitmapObject|nil resized +function Bitmap.resize(bmp, width, height) end + +--- @deprecated Use `bitmap.toMask` instead. +---@param bmp BitmapObject +---@return string mask +function Bitmap.toMask(bmp) end diff --git a/types/dir.d.lua b/types/dir.d.lua new file mode 100644 index 0000000..90d243f --- /dev/null +++ b/types/dir.d.lua @@ -0,0 +1,58 @@ +---@meta + +-- EdgeTX filesystem module +-- Source: edgetx/radio/src/lua/api_filesystem.cpp + +---@class dir +dir = {} + +--- Return an iterator listing all file and directory names in a directory. +--- @since 2.5.0 +---@param path? string Working directory +---@return fun():string|nil iterator Returns filenames one at a time +function dir.dir(path) end + +--- Check the existence of a file or directory. Returns nil if not found. +--- @since 2.5.0 +---@param path? string Path to the object +---@return {size: integer, attrib: integer, time: {year: integer, mon: integer, day: integer, hour: integer, hour12: integer, min: integer, sec: integer, suffix: string}}|nil stat +function dir.fstat(path) end + +--- Delete a file or directory. +--- @since 2.9.0 +---@param path? string Path to file or directory to delete +---@return integer result FRESULT (0=OK, 4=File not found, 5=Path not found, 6=Path invalid) +function dir.del(path) end + +--- Change the working directory. +--- @since 2.3.0 +---@param path? string New working directory +function dir.chdir(path) end + +--- Create a directory. +--- @since 2.11.0 +---@param path string Directory path to create +---@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) +function dir.mkdir(path) end + +--- Rename or move a file or directory. The new parent directory must already exist. +--- @since 2.11.0 +---@param oldPath string Current path +---@param newPath string New path +---@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) +function dir.rename(oldPath, newPath) end + +-- ========================================================================== +-- File attribute constants (for dir.fstat attrib field) +-- ========================================================================== + +---@type integer +AM_RDO = 0 +---@type integer +AM_HID = 0 +---@type integer +AM_SYS = 0 +---@type integer +AM_DIR = 0 +---@type integer +AM_ARC = 0 diff --git a/types/edgetx.d.lua b/types/edgetx.d.lua new file mode 100644 index 0000000..f719577 --- /dev/null +++ b/types/edgetx.d.lua @@ -0,0 +1,396 @@ +---@meta + +-- EdgeTX global functions and constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +-- ========================================================================== +-- System information +-- ========================================================================== + +--- Return EdgeTX version information. +--- @since 2.0.0 +---@return string version Full version string (e.g. "2.11.0") +---@return string radio Radio type string (e.g. "tx16s-2.11.0") +---@return integer major Major version number +---@return integer minor Minor version number +---@return integer revision Revision number +---@return string osname OS name (e.g. "EdgeTX") +function getVersion() end + +--- Return general radio settings. +--- @since 2.0.0 +---@return {battWarn: number, battMin: number, battMax: number, imperial: integer, language: string, voice: string, gtimer: integer} settings +function getGeneralSettings() end + +--- Return radio global timer data. +--- @since 2.3.0 +---@return {total: integer, session: integer, throttle: integer, throttlepct: integer} +function getGlobalTimer() end + +--- Reset one or all radio global timers. +--- @since 2.3.0 +---@param which? string "total"|"session"|"throttle"|"throttlepct" (default "total") +function resetGlobalTimer(which) end + +--- Return rotary encoder speed. +---@return integer speed ROTENC_LOWSPEED, ROTENC_MIDSPEED, or ROTENC_HIGHSPEED +function getRotEncSpeed() end + +--- Return rotary encoder mode. +---@return integer mode +function getRotEncMode() end + +--- Return the current stick mode (1-4). +---@return integer mode +function getStickMode() end + +-- ========================================================================== +-- Source and value functions +-- ========================================================================== + +--- Return the current value of a source. GPS and cells return tables. +--- @since 2.0.0 +---@param source integer|string Source index or name +---@return integer|number|string|table|nil value +function getValue(source) end + +--- Return the current output value for a channel (after all processing). +---@param index integer Channel index (0-based) +---@return integer value +function getOutputValue(index) end + +--- Return the value of a source with freshness information. +---@param source integer|string Source index or name +---@return integer|number|string|table|nil value +---@return boolean isCurrent True if telemetry stream is currently active +---@return boolean isFresh True if data has been recently updated +function getSourceValue(source) end + +--- Return information about a field/source. Returns nil if source not found. +--- For telemetry sources, the `unit` field is also included. +--- @since 2.0.8 +---@param source integer|string Source index or name +---@return {id: integer, name: string, desc: string, unit?: integer}|nil info +function getFieldInfo(source) end + +--- Return information about a source. Same as getFieldInfo(). +---@param source integer|string Source index or name +---@return {id: integer, name: string, desc: string, unit?: integer}|nil info +function getSourceInfo(source) end + +--- Get the source index for a named source. +---@param name string Source name +---@return integer|nil index +function getSourceIndex(name) end + +--- Get the display name for a source index. +---@param index integer Source index +---@return string|nil name +function getSourceName(index) end + +--- Return an iterator over sources. Optional first/last to limit range. +---@param first? integer Starting source index +---@param last? integer Ending source index +---@return function iterator +function sources(first, last) end + +-- ========================================================================== +-- Switch functions +-- ========================================================================== + +--- Set a function switch (sticky switch) state. +---@param index integer Switch index +---@param value boolean +---@return boolean bufferFull True if the command buffer is full +function setStickySwitch(index, value) end + +--- Get the value of a logical switch. +---@param index integer Logical switch index (0-based) +---@return boolean|nil value nil if index is invalid +function getLogicalSwitchValue(index) end + +--- Get information about a physical switch position. +--- @since 2.6.0 +---@param index integer Switch source index (MIXSRC_FIRST_SWITCH-based) +---@return {type: integer, isCustomisableSwitch: boolean, name: string}|nil info +function getSwitchInfo(index) end + +--- Get the switch index for a named switch position. +--- @since 2.6.0 +---@param name string Switch position name (as shown in radio menus) +---@return integer|nil index +function getSwitchIndex(name) end + +--- Get the display name for a switch index. +--- @since 2.6.0 +---@param index integer Switch index +---@return string|nil name +function getSwitchName(index) end + +--- Get the current value of a switch. +---@param index integer Switch index +---@return boolean|nil value nil if index is invalid +function getSwitchValue(index) end + +--- Return an iterator over switch positions. Optional first/last to limit range. +---@param first? integer Starting switch index +---@param last? integer Ending switch index +---@return function iterator +function switches(first, last) end + +-- ========================================================================== +-- Stick and channel functions +-- ========================================================================== + +--- Return the default stick index for a given channel. +--- @since 2.0.0 +---@param channel integer Channel number (0-3) +---@return integer stick Stick index (0-3) +function defaultStick(channel) end + +--- Return the default channel for a given stick. Returns nil if not found. +--- @since 2.0.0 +---@param stick integer Stick index (0-3) +---@return integer|nil channel +function defaultChannel(stick) end + +-- ========================================================================== +-- Flight mode +-- ========================================================================== + +--- Return the current flight mode number and name. +--- @since 2.1.7 +---@param index? integer Flight mode index (default: current active mode) +---@return integer mode +---@return string name +function getFlightMode(index) end + +-- ========================================================================== +-- UI / popup functions +-- ========================================================================== + +--- Take a screenshot and save to SD card. +function screenshot() end + +--- Show an input popup dialog with adjustable numeric value. +--- @since 2.0.0 +---@param title string +---@param event integer +---@param input integer Current value +---@param min integer Minimum value +---@param max integer Maximum value +---@return integer|string result "OK", "CANCEL", or current input value +function popupInput(title, event, input, min, max) end + +--- Show a warning popup. Returns "CANCEL" when dismissed, nil while still showing. +--- @since 2.0.0 +---@param title string +---@param event integer +---@return string|nil result "CANCEL" when dismissed, nil while still showing +function popupWarning(title, event) end + +--- Show a confirmation popup with OK/Cancel buttons. +--- @since 2.0.0 +---@overload fun(title: string, event: integer): string|nil @Deprecated 2-arg form +---@param title string +---@param message string +---@param event? integer +---@return string|nil result "OK", "CANCEL", or nil while still showing +function popupConfirmation(title, message, event) end + +--- Stop key repeat events for the given key. +---@param event integer Key event to kill +function killEvents(event) end + +-- ========================================================================== +-- Touch event constants (color LCD with touch) +-- ========================================================================== + +---@type integer +EVT_TOUCH_FIRST = 0 +---@type integer +EVT_TOUCH_BREAK = 0 +---@type integer +EVT_TOUCH_SLIDE = 0 +---@type integer +EVT_TOUCH_TAP = 0 + +-- ========================================================================== +-- Virtual event constants +-- ========================================================================== + +---@type integer +EVT_VIRTUAL_PREV = 0 +---@type integer +EVT_VIRTUAL_PREV_REPT = 0 +---@type integer +EVT_VIRTUAL_NEXT = 0 +---@type integer +EVT_VIRTUAL_NEXT_REPT = 0 +---@type integer +EVT_VIRTUAL_DEC = 0 +---@type integer +EVT_VIRTUAL_DEC_REPT = 0 +---@type integer +EVT_VIRTUAL_INC = 0 +---@type integer +EVT_VIRTUAL_INC_REPT = 0 +---@type integer +EVT_VIRTUAL_PREV_PAGE = 0 +---@type integer +EVT_VIRTUAL_NEXT_PAGE = 0 +---@type integer +EVT_VIRTUAL_MENU = 0 +---@type integer +EVT_VIRTUAL_MENU_LONG = 0 +---@type integer +EVT_VIRTUAL_ENTER = 0 +---@type integer +EVT_VIRTUAL_ENTER_LONG = 0 +---@type integer +EVT_VIRTUAL_EXIT = 0 +---@type integer +EVT_EXIT_BREAK = 0 + +-- ========================================================================== +-- Key event constants (generated per-radio, superset listed) +-- ========================================================================== + +---@type integer +EVT_ENTER_FIRST = 0 +---@type integer +EVT_ENTER_BREAK = 0 +---@type integer +EVT_ENTER_LONG = 0 +---@type integer +EVT_ENTER_REPT = 0 +---@type integer +EVT_MENU_FIRST = 0 +---@type integer +EVT_MENU_BREAK = 0 +---@type integer +EVT_MENU_LONG = 0 +---@type integer +EVT_MENU_REPT = 0 +---@type integer +EVT_TELEM_FIRST = 0 +---@type integer +EVT_TELEM_BREAK = 0 +---@type integer +EVT_TELEM_LONG = 0 +---@type integer +EVT_TELEM_REPT = 0 +---@type integer +EVT_MODEL_FIRST = 0 +---@type integer +EVT_MODEL_BREAK = 0 +---@type integer +EVT_MODEL_LONG = 0 +---@type integer +EVT_MODEL_REPT = 0 +---@type integer +EVT_SYS_FIRST = 0 +---@type integer +EVT_SYS_BREAK = 0 +---@type integer +EVT_SYS_LONG = 0 +---@type integer +EVT_SYS_REPT = 0 +---@type integer +EVT_UP_FIRST = 0 +---@type integer +EVT_UP_BREAK = 0 +---@type integer +EVT_UP_LONG = 0 +---@type integer +EVT_UP_REPT = 0 +---@type integer +EVT_DOWN_FIRST = 0 +---@type integer +EVT_DOWN_BREAK = 0 +---@type integer +EVT_DOWN_LONG = 0 +---@type integer +EVT_DOWN_REPT = 0 +---@type integer +EVT_LEFT_FIRST = 0 +---@type integer +EVT_LEFT_BREAK = 0 +---@type integer +EVT_LEFT_LONG = 0 +---@type integer +EVT_LEFT_REPT = 0 +---@type integer +EVT_RIGHT_FIRST = 0 +---@type integer +EVT_RIGHT_BREAK = 0 +---@type integer +EVT_RIGHT_LONG = 0 +---@type integer +EVT_RIGHT_REPT = 0 +---@type integer +EVT_PAGEUP_FIRST = 0 +---@type integer +EVT_PAGEUP_BREAK = 0 +---@type integer +EVT_PAGEUP_LONG = 0 +---@type integer +EVT_PAGEUP_REPT = 0 +---@type integer +EVT_PAGEDN_FIRST = 0 +---@type integer +EVT_PAGEDN_BREAK = 0 +---@type integer +EVT_PAGEDN_LONG = 0 +---@type integer +EVT_PAGEDN_REPT = 0 +---@type integer +EVT_PAGE_FIRST = 0 +---@type integer +EVT_PAGE_BREAK = 0 +---@type integer +EVT_PAGE_LONG = 0 +---@type integer +EVT_PAGE_REPT = 0 +---@type integer +EVT_PLUS_FIRST = 0 +---@type integer +EVT_PLUS_BREAK = 0 +---@type integer +EVT_PLUS_LONG = 0 +---@type integer +EVT_PLUS_REPT = 0 +---@type integer +EVT_MINUS_FIRST = 0 +---@type integer +EVT_MINUS_BREAK = 0 +---@type integer +EVT_MINUS_LONG = 0 +---@type integer +EVT_MINUS_REPT = 0 +---@type integer +EVT_SHIFT_FIRST = 0 +---@type integer +EVT_SHIFT_BREAK = 0 +---@type integer +EVT_SHIFT_LONG = 0 +---@type integer +EVT_SHIFT_REPT = 0 +---@type integer +EVT_ROT_FIRST = 0 +---@type integer +EVT_ROT_BREAK = 0 +---@type integer +EVT_ROT_LONG = 0 +---@type integer +EVT_ROT_REPT = 0 +---@type integer +EVT_ROT_LEFT = 0 +---@type integer +EVT_ROT_RIGHT = 0 +---@type integer +ROTENC_LOWSPEED = 0 +---@type integer +ROTENC_MIDSPEED = 0 +---@type integer +ROTENC_HIGHSPEED = 0 diff --git a/types/hardware.d.lua b/types/hardware.d.lua new file mode 100644 index 0000000..6789b6b --- /dev/null +++ b/types/hardware.d.lua @@ -0,0 +1,43 @@ +---@meta + +-- EdgeTX LED and IMU hardware functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Set the color of an RGB LED in the strip. +---@param index integer LED index (0-based) +---@param red integer 0-255 +---@param green integer 0-255 +---@param blue integer 0-255 +---@return boolean success +function setRGBLedColor(index, red, green, blue) end + +--- Apply all pending RGB LED color changes. +function applyRGBLedColors() end + +--- Set or cancel a CFS (Customizable Function Switch) LED color override. +--- Call with only the name to cancel the override. +---@param name string Switch name +---@param red? integer 0-255 (omit all color args to cancel override) +---@param green? integer 0-255 +---@param blue? integer 0-255 +---@return boolean success +function setCFSLedColor(name, red, green, blue) end + +--- Set the IMU X-axis calibration parameters. +---@param offset integer Degrees (-180 to 180) +---@param range integer Degrees (0 to 180) +---@return boolean success +function setIMU_X(offset, range) end + +--- Set the IMU Y-axis calibration parameters. +---@param offset integer Degrees (-180 to 180) +---@param range integer Degrees (0 to 180) +---@return boolean success +function setIMU_Y(offset, range) end + +-- ========================================================================== +-- Hardware constants +-- ========================================================================== + +---@type integer +LED_STRIP_LENGTH = 0 diff --git a/types/lcd.d.lua b/types/lcd.d.lua new file mode 100644 index 0000000..a067adc --- /dev/null +++ b/types/lcd.d.lua @@ -0,0 +1,528 @@ +---@meta + +-- EdgeTX LCD drawing module +-- Source: edgetx/radio/src/lua/api_colorlcd.cpp, api_stdlcd.cpp + +---@class lcd +lcd = {} + +--- Refresh the LCD screen. On color LCDs (2.4.0+) this is done automatically. +--- @since 2.0.0 +function lcd.refresh() end + +--- Clear the LCD screen. +--- @since 2.0.0 +---@param color? integer Fill color (color LCD only, defaults to COLOR_THEME_SECONDARY3) +function lcd.clear(color) end + +--- Reset the backlight timeout. +--- @since 2.3.6 +function lcd.resetBacklightTimeout() end + +--- Draw a single pixel at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param flags? integer Drawing flags +function lcd.drawPoint(x, y, flags) end + +--- Draw a straight line between two points. +--- If either endpoint is outside the LCD, the whole line will not be drawn. +--- @since 2.0.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param pattern integer SOLID or DOTTED +---@param flags? integer Drawing flags +function lcd.drawLine(x1, y1, x2, y2, pattern, flags) end + +--- Draw a rectangle outline. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param flags? integer Drawing flags +---@param thickness? integer Border thickness in pixels (color LCD only, default 1) +---@param opacity? integer 0-15 (color LCD only, default 0) +function lcd.drawRectangle(x, y, w, h, flags, thickness, opacity) end + +--- Draw a solid filled rectangle. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param flags? integer Drawing flags +---@param opacity? integer 0-15 (color LCD only, default 0) +function lcd.drawFilledRectangle(x, y, w, h, flags, opacity) end + +--- Invert a rectangular region. Color LCD only. +--- @since 2.8.0 +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param flags? integer Drawing flags +function lcd.invertRect(x, y, w, h, flags) end + +--- Draw a text string at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param text string +---@param flags? integer Drawing flags (font size, alignment, color, INVERS, BLINK, SHADOWED, etc.) +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawText(x, y, text, flags, inversColor) end + +--- Draw text inside a bounding rectangle with automatic line breaks. +--- Color LCD only. RIGHT, CENTER, VCENTER are not implemented. +--- @since 2.5.0 +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param text string +---@param flags? integer Drawing flags +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +---@return integer x X position where text drawing ended +---@return integer y Y position where text drawing ended +function lcd.drawTextLines(x, y, w, h, text, flags, inversColor) end + +--- Get the width and height of a text string when drawn with the given flags. +--- Color LCD only. +--- @since 2.5.0 +---@param text string +---@param flags? integer Drawing flags (font size, etc.) +---@return integer width +---@return integer height +function lcd.sizeText(text, flags) end + +--- Display a value formatted as time at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param value integer Time in seconds +---@param flags? integer Drawing flags (use TIMEHOUR to show hours) +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawTimer(x, y, value, flags, inversColor) end + +--- Display a number at (x,y). Use PREC1/PREC2 flags for decimal precision. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param value integer +---@param flags? integer Drawing flags +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawNumber(x, y, value, flags, inversColor) end + +--- Display a telemetry value at (x,y). +--- @since 2.0.6 +---@param x integer +---@param y integer +---@param source integer|string Source index or name (only telemetry sources are valid) +---@param flags? integer Drawing flags +function lcd.drawChannel(x, y, source, flags) end + +--- Draw the text representation of a switch at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param switch integer Switch index; negative number shows negated switch +---@param flags? integer Drawing flags +function lcd.drawSwitch(x, y, switch, flags) end + +--- Display the name of the corresponding source at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param source integer Source index +---@param flags? integer Drawing flags +function lcd.drawSource(x, y, source, flags) end + +--- Draw a horizontal gauge bar. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param fill integer Current fill amount +---@param maxfill integer Maximum fill value +---@param flags? integer Drawing flags +function lcd.drawGauge(x, y, w, h, fill, maxfill, flags) end + +--- Draw a .bmp pixmap at (x,y). B&W LCD only; on color LCD use lcd.drawBitmap(). +---@param x integer +---@param y integer +---@param name string File path on SD card +function lcd.drawPixmap(x, y, name) end + +--- Draw a title bar with page counter. B&W LCD only. +---@param title string +---@param page integer Current page (1-based) +---@param pages integer Total number of pages +function lcd.drawScreenTitle(title, page, pages) end + +--- Draw a combobox (dropdown selector). B&W LCD only. +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param list table Array of option strings +---@param idx integer Selected index +---@param flags integer Drawing flags +function lcd.drawCombobox(x, y, w, list, idx, flags) end + +--- Display a bitmap at (x,y). Color LCD only. +--- Omitting scale draws at 1:1 and is faster than specifying 100. +--- @since 2.2.0 +---@param bmp BitmapObject Bitmap opened with bitmap.open() +---@param x integer +---@param y integer +---@param scale? integer Percentage (0 or omit = 1:1, 50 = half, 200 = double) +function lcd.drawBitmap(bmp, x, y, scale) end + +--- Display a bitmap pattern (mask) at (x,y) using the current color. Color LCD only. +--- @since 2.8.0 +---@param mask string Binary mask data from bitmap.toMask() +---@param x integer +---@param y integer +---@param flags? integer Drawing flags (color) +function lcd.drawBitmapPattern(mask, x, y, flags) end + +--- Display a pie-shaped slice of a bitmap pattern. Color LCD only. +--- @since 2.8.0 +---@param mask string Binary mask data from bitmap.toMask() +---@param x integer +---@param y integer +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags (color) +function lcd.drawBitmapPatternPie(mask, x, y, startAngle, endAngle, flags) end + +--- Change an indexed color (theme colors or CUSTOM_COLOR). +--- Changing theme colors affects all widgets and the radio UI. Color LCD only. +--- @since 2.2.0 +---@param colorIndex integer One of the COLOR_THEME_* or CUSTOM_COLOR constants +---@param color integer RGB color value (from lcd.RGB() or another color constant) +function lcd.setColor(colorIndex, color) end + +--- Get the RGB565 color value from a color flag. Color LCD only. +--- @since 2.3.11 +---@param colorIndex integer Color constant or flags containing color +---@return integer|nil color RGB565 color value, or nil for invalid input +function lcd.getColor(colorIndex) end + +--- Create an RGB color flag from components. Color LCD only. +--- Can be called as lcd.RGB(r, g, b) or lcd.RGB(rgb). +--- @since 2.2.0 +---@overload fun(rgb: integer): integer +---@param r integer 0-255 +---@param g integer 0-255 +---@param b integer 0-255 +---@return integer color Color flag with RGB565 value +function lcd.RGB(r, g, b) end + +--- Draw a circle outline. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param flags? integer Drawing flags +function lcd.drawCircle(x, y, r, flags) end + +--- Draw a filled circle. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param flags? integer Drawing flags +function lcd.drawFilledCircle(x, y, r, flags) end + +--- Draw a triangle outline. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param x3 integer +---@param y3 integer +---@param flags? integer Drawing flags +function lcd.drawTriangle(x1, y1, x2, y2, x3, y3, flags) end + +--- Draw a filled triangle. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param x3 integer +---@param y3 integer +---@param flags? integer Drawing flags +function lcd.drawFilledTriangle(x1, y1, x2, y2, x3, y3, flags) end + +--- Draw an arc (ring segment). Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawArc(x, y, r, startAngle, endAngle, flags) end + +--- Draw a filled pie slice. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawPie(x, y, r, startAngle, endAngle, flags) end + +--- Draw a filled annulus (ring sector). Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param rInner integer Inner radius +---@param rOuter integer Outer radius +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawAnnulus(x, y, rInner, rOuter, startAngle, endAngle, flags) end + +--- Draw a line clipped to a rectangle. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param xmin integer Clip region left +---@param xmax integer Clip region right +---@param ymin integer Clip region top +---@param ymax integer Clip region bottom +---@param pattern integer FORCE, ERASE, or DOTTED +---@param flags? integer Drawing flags +function lcd.drawLineWithClipping(x1, y1, x2, y2, xmin, xmax, ymin, ymax, pattern, flags) end + +--- Draw a HUD-style horizon rectangle with pitch and roll perspective. Color LCD only. +--- @since 2.4.0 +---@param pitch number Pitch angle in degrees +---@param roll number Roll angle in degrees +---@param xmin integer +---@param xmax integer +---@param ymin integer +---@param ymax integer +---@param flags? integer Drawing flags +function lcd.drawHudRectangle(pitch, roll, xmin, xmax, ymin, ymax, flags) end + +--- Exit full screen widget mode. Color LCD only. +--- @since 2.5.0 +function lcd.exitFullScreen() end + +--- Get the X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastPos() end + +--- Get the right edge X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastRightPos() end + +--- Get the left edge X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastLeftPos() end + +-- ========================================================================== +-- Display helper functions +-- ========================================================================== + +--- Convert a value (0-15) to a grey color flag. B&W LCD only (not color LCD). +---@param value integer Grey level (0-15) +---@return integer color +function GREY(value) end + +-- ========================================================================== +-- Display flag constants +-- ========================================================================== + +---@type integer +FULLSCALE = 0 +---@type integer +STDSIZE = 0 +---@type integer +XXLSIZE = 0 +---@type integer +DBLSIZE = 0 +---@type integer +MIDSIZE = 0 +---@type integer +SMLSIZE = 0 +---@type integer +TINSIZE = 0 +---@type integer +BOLD = 0 +---@type integer +BLINK = 0 +---@type integer +INVERS = 0 +---@type integer +VCENTER = 0 +---@type integer +VTOP = 0 +---@type integer +VBOTTOM = 0 +---@type integer +RIGHT = 0 +---@type integer +LEFT = 0 +---@type integer +CENTER = 0 +---@type integer +PREC1 = 0 +---@type integer +PREC2 = 0 +---@type integer +SHADOWED = 0 +---@type integer +FIXEDWIDTH = 0 + +-- ========================================================================== +-- Color constants (color LCD only) +-- ========================================================================== + +---@type integer +COLOR_THEME_PRIMARY1 = 0 +---@type integer +COLOR_THEME_PRIMARY2 = 0 +---@type integer +COLOR_THEME_PRIMARY3 = 0 +---@type integer +COLOR_THEME_SECONDARY1 = 0 +---@type integer +COLOR_THEME_SECONDARY2 = 0 +---@type integer +COLOR_THEME_SECONDARY3 = 0 +---@type integer +COLOR_THEME_FOCUS = 0 +---@type integer +COLOR_THEME_EDIT = 0 +---@type integer +COLOR_THEME_ACTIVE = 0 +---@type integer +COLOR_THEME_WARNING = 0 +---@type integer +COLOR_THEME_DISABLED = 0 +---@type integer +CUSTOM_COLOR = 0 +---@type integer +BLACK = 0 +---@type integer +WHITE = 0 +---@type integer +LIGHTWHITE = 0 +---@type integer +LIGHTGREY = 0 +---@type integer +DARKGREY = 0 +---@type integer +RED = 0 +---@type integer +DARKRED = 0 +---@type integer +LIGHTRED = 0 +---@type integer +GREEN = 0 +---@type integer +DARKGREEN = 0 +---@type integer +BRIGHTGREEN = 0 +---@type integer +BLUE = 0 +---@type integer +DARKBLUE = 0 +---@type integer +CYAN = 0 +---@type integer +YELLOW = 0 +---@type integer +LIGHTBROWN = 0 +---@type integer +DARKBROWN = 0 +---@type integer +ORANGE = 0 +---@type integer +MAGENTA = 0 + +-- ========================================================================== +-- Drawing pattern constants +-- ========================================================================== + +---@type integer +SOLID = 0 +---@type integer +DOTTED = 0 +---@type integer +FILL_WHITE = 0 +---@type integer +GREY_DEFAULT = 0 +---@type integer +FORCE = 0 +---@type integer +ERASE = 0 +---@type integer +ROUND = 0 + +-- ========================================================================== +-- Screen dimension constants +-- ========================================================================== + +---@type integer +LCD_W = 0 +---@type integer +LCD_H = 0 +---@type integer +MENU_HEADER_HEIGHT = 0 + +-- ========================================================================== +-- Special character constants (light userdata) +-- ========================================================================== + +---@type any +CHAR_RIGHT = nil +---@type any +CHAR_LEFT = nil +---@type any +CHAR_UP = nil +---@type any +CHAR_DOWN = nil +---@type any +CHAR_DELTA = nil +---@type any +CHAR_STICK = nil +---@type any +CHAR_POT = nil +---@type any +CHAR_SLIDER = nil +---@type any +CHAR_SWITCH = nil +---@type any +CHAR_TRIM = nil +---@type any +CHAR_INPUT = nil +---@type any +CHAR_FUNCTION = nil +---@type any +CHAR_CYC = nil +---@type any +CHAR_TRAINER = nil +---@type any +CHAR_CHANNEL = nil +---@type any +CHAR_TELEMETRY = nil +---@type any +CHAR_LUA = nil +---@type any +CHAR_LS = nil +---@type any +CHAR_CURVE = nil diff --git a/types/lua-runtime.d.lua b/types/lua-runtime.d.lua new file mode 100644 index 0000000..cef07ac --- /dev/null +++ b/types/lua-runtime.d.lua @@ -0,0 +1,94 @@ +---@meta + +-- EdgeTX Lua runtime overrides +-- Only libraries that differ from standard Lua 5.3 are redefined here. +-- string, math (core), bit32, table, and base globals use LuaLS builtins. + +-- ========================================================================== +-- io library (5 functions only, non-standard signatures) +-- EdgeTX uses FatFs; all functions take a file handle as the first argument. +-- No file methods (:read, :write, etc.), no io.lines, io.input, io.output. +-- ========================================================================== + +---@class file* + +io = {} + +--- Open a file on the SD card. Returns file handle on success, or nil + error +--- message on failure. +---@param filename string Full path on SD card +---@param mode? "r"|"rb"|"w"|"wb"|"a"|"ab" Default "r". Only the first character matters; 'b' is accepted but ignored. +---@return file*|nil handle +---@return string|nil errmsg +function io.open(filename, mode) end + +--- Read up to `size` bytes from the file. Returns an empty string at end of file. +---@param file file* +---@param size integer Number of bytes to read +---@return string data Empty string on EOF +function io.read(file, size) end + +--- Write values to the file. Accepts strings and numbers. +---@param file file* +---@param ... string|number +---@return file* file The file handle on success +function io.write(file, ...) end + +--- Close the file handle. +---@param file file* +function io.close(file) end + +--- Seek to an absolute byte offset in the file. +---@param file file* +---@param offset integer Absolute byte offset (unsigned) +---@return integer result FRESULT (0 = OK) +function io.seek(file, offset) end + +-- ========================================================================== +-- math compat functions (LUA_COMPAT_5_2 / LUA_COMPAT_MATHLIB) +-- These are deprecated in standard Lua 5.3 but available in EdgeTX. +-- ========================================================================== + +---@deprecated Use `math.atan(y, x)` instead +---@param y number +---@param x? number +---@return number +function math.atan2(y, x) end + +---@deprecated +---@param x number +---@return number +function math.cosh(x) end + +---@deprecated +---@param x number +---@return number +function math.sinh(x) end + +---@deprecated +---@param x number +---@return number +function math.tanh(x) end + +---@deprecated Use `x ^ y` instead +---@param x number +---@param y number +---@return number +function math.pow(x, y) end + +---@deprecated +---@param x number +---@return number m +---@return integer e +function math.frexp(x) end + +---@deprecated +---@param m number +---@param e integer +---@return number +function math.ldexp(m, e) end + +---@deprecated Use `math.log(x, 10)` instead +---@param x number +---@return number +function math.log10(x) end diff --git a/types/lvgl.d.lua b/types/lvgl.d.lua new file mode 100644 index 0000000..e9f67b3 --- /dev/null +++ b/types/lvgl.d.lua @@ -0,0 +1,848 @@ +---@meta + +-- EdgeTX LVGL module (color LCD only) +-- Source: edgetx/radio/src/lua/api_colorlcd_lvgl.cpp + +---@class LvglObject +local LvglObject = {} + +---@param settings LvglLabelSettings +---@return LvglObject +function LvglObject:label(settings) end + +---@param settings LvglRectangleSettings +---@return LvglObject +function LvglObject:rectangle(settings) end + +---@param settings LvglBoxSettings +---@return LvglObject +function LvglObject:box(settings) end + +---@param settings LvglButtonSettings +---@return LvglObject +function LvglObject:button(settings) end + +---@param settings LvglMomentaryButtonSettings +---@return LvglObject +function LvglObject:momentaryButton(settings) end + +---@param settings LvglToggleSettings +---@return LvglObject +function LvglObject:toggle(settings) end + +---@param settings LvglTextEditSettings +---@return LvglObject +function LvglObject:textEdit(settings) end + +---@param settings LvglNumberEditSettings +---@return LvglObject +function LvglObject:numberEdit(settings) end + +---@param settings LvglChoiceSettings +---@return LvglObject +function LvglObject:choice(settings) end + +---@param settings LvglSliderSettings +---@return LvglObject +function LvglObject:slider(settings) end + +---@param settings LvglSettingSettings +---@return LvglObject +function LvglObject:setting(settings) end + +---@param settings LvglHlineSettings +---@return LvglObject +function LvglObject:hline(settings) end + +---@param settings LvglVlineSettings +---@return LvglObject +function LvglObject:vline(settings) end + +---@param settings LvglLineSettings +---@return LvglObject +function LvglObject:line(settings) end + +---@param settings LvglTriangleSettings +---@return LvglObject +function LvglObject:triangle(settings) end + +---@param settings LvglCircleSettings +---@return LvglObject +function LvglObject:circle(settings) end + +---@param settings LvglArcSettings +---@return LvglObject +function LvglObject:arc(settings) end + +---@param settings LvglImageSettings +---@return LvglObject +function LvglObject:image(settings) end + +---@param settings LvglQrcodeSettings +---@return LvglObject +function LvglObject:qrcode(settings) end + +---@param settings LvglSourceSettings +---@return LvglObject +function LvglObject:source(settings) end + +---@param settings LvglSwitchSettings +---@return LvglObject +function LvglObject:switch(settings) end + +function LvglObject:show() end +function LvglObject:hide() end +function LvglObject:enable() end +function LvglObject:disable() end +function LvglObject:close() end +function LvglObject:clear() end + +---@param tree table[] +---@return table +function LvglObject:build(tree) end + +---@param settings table +function LvglObject:set(settings) end + +---@return integer x +---@return integer y +function LvglObject:getScrollPos() end + +-- ========================================================================== +-- Common settings (inherited by all widgets via LvglWidgetObjectBase) +-- ========================================================================== + +---@class LvglCommonSettings +---@field x? integer X position (or PERCENT_SIZE+N for percentage) +---@field y? integer Y position +---@field w? integer Width (0 = auto size to fit content, or PERCENT_SIZE+N) +---@field h? integer Height (0 = auto size to fit content, or PERCENT_SIZE+N) +---@field color? integer|fun():integer Primary color +---@field opacity? integer|fun():integer 0-255 +---@field visible? fun():boolean Dynamic visibility +---@field size? fun(w: integer, h: integer): integer, integer Dynamic size callback +---@field pos? fun(): integer, integer Dynamic position callback + +-- ========================================================================== +-- Page +-- ========================================================================== + +--- Full-screen page layout with header bar, title, and optional navigation. +---@class LvglPageSettings +---@field title? string|fun():string Header title text +---@field subtitle? string|fun():string Header subtitle text +---@field icon? string Path to 30x30 grayscale mask image +---@field back? fun() Called on back button / RTN key +---@field menu? fun() Called on menu button +---@field prevButton? {press: fun(), active?: fun():boolean} +---@field nextButton? {press: fun(), active?: fun():boolean} +---@field backButton? boolean Show exit/back button in header right +---@field flexFlow? integer FLOW_ROW or FLOW_COLUMN +---@field flexPad? integer Padding between flex items +---@field scrollBar? boolean +---@field scrollDir? integer SCROLL_OFF, SCROLL_HOR, SCROLL_VER, SCROLL_ALL +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field align? integer Flex layout alignment (LEFT, CENTER, RIGHT, VTOP, VCENTER, VBOTTOM) +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean + +-- ========================================================================== +-- Dialog +-- ========================================================================== + +--- Modal dialog with a title bar and close button. +---@class LvglDialogSettings : LvglCommonSettings +---@field title? string|fun():string +---@field flexFlow? integer +---@field flexPad? integer +---@field close? fun() Called when dialog is closed +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean + +-- ========================================================================== +-- Confirm / Message popups +-- ========================================================================== + +--- Yes/No confirmation dialog. +---@class LvglConfirmSettings +---@field title? string|fun():string +---@field message? string +---@field confirm? fun() Called when Yes is tapped +---@field cancel? fun() Called when No is tapped + +--- Information message dialog. +---@class LvglMessageSettings +---@field title? string|fun():string +---@field message? string +---@field details? string Additional detail text + +-- ========================================================================== +-- Menu +-- ========================================================================== + +--- Popup menu for selecting from a list of options. +---@class LvglMenuSettings +---@field title? string|fun():string +---@field values? string[] List of menu item strings +---@field get? fun():integer Current selection index (1-based) +---@field set? fun(index: integer) Selection changed + +-- ========================================================================== +-- Label +-- ========================================================================== + +--- Display a text label. +---@class LvglLabelSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer Font flag (BOLD, SMLSIZE, MIDSIZE, DBLSIZE, etc.) +---@field align? integer|fun():integer Text alignment (LEFT, CENTER, RIGHT, VCENTER, etc.) +---@field children? table[] + +-- ========================================================================== +-- Rectangle +-- ========================================================================== + +--- Display a rectangle (outline or filled). +---@class LvglRectangleSettings : LvglCommonSettings +---@field thickness? integer Border thickness (0 = invisible border) +---@field filled? boolean|fun():boolean +---@field rounded? integer Corner radius (must be >= thickness if > 0) +---@field flexFlow? integer +---@field flexPad? integer +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field align? integer|fun():integer +---@field scrollBar? boolean +---@field scrollDir? integer +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Box (layout container) +-- ========================================================================== + +--- Invisible container for managing child layout. +---@class LvglBoxSettings : LvglCommonSettings +---@field flexFlow? integer FLOW_ROW or FLOW_COLUMN +---@field flexPad? integer Padding between flex items +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field align? integer|fun():integer +---@field scrollBar? boolean +---@field scrollDir? integer +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Button +-- ========================================================================== + +--- Text button using the EdgeTX style. +---@class LvglButtonSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer +---@field cornerRadius? integer +---@field textColor? integer|fun():integer +---@field press? fun() Called when button is tapped +---@field longpress? fun() Called on long press +---@field checked? boolean Initial checked state +---@field active? fun():boolean + +-- ========================================================================== +-- Momentary Button +-- ========================================================================== + +--- Momentary button that fires on press and release. +---@class LvglMomentaryButtonSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer +---@field cornerRadius? integer +---@field textColor? integer|fun():integer +---@field press? fun() Called on press +---@field release? fun() Called on release +---@field active? fun():boolean + +-- ========================================================================== +-- Toggle +-- ========================================================================== + +--- Toggle switch using the EdgeTX style. +---@class LvglToggleSettings : LvglCommonSettings +---@field get? fun():integer Returns 0 or 1 +---@field set? fun(value: integer) Receives 0 or 1 +---@field active? fun():boolean + +-- ========================================================================== +-- Text Edit +-- ========================================================================== + +--- Text input field. +---@class LvglTextEditSettings : LvglCommonSettings +---@field value? string Initial text value +---@field length? integer Max text length (1-128, default 32) +---@field set? fun(value: string) Called when text is changed +---@field active? fun():boolean + +-- ========================================================================== +-- Number Edit +-- ========================================================================== + +--- Numeric input field with +/- adjustment. +---@class LvglNumberEditSettings : LvglCommonSettings +---@field min? integer Default -1024 +---@field max? integer Default 1024 +---@field get? fun():integer +---@field set? fun(value: integer) Called on user interaction +---@field edited? fun(value: integer) Called when editing is committed +---@field display? fun(value: integer):string Custom display formatting +---@field active? fun():boolean + +-- ========================================================================== +-- Choice (dropdown) +-- ========================================================================== + +--- Button that opens a popup menu for selecting from a list. +---@class LvglChoiceSettings : LvglCommonSettings +---@field title? string|fun():string Popup title +---@field values? string[] List of option strings +---@field get? fun():integer Current selection index (1-based) +---@field set? fun(index: integer) Selection changed (receives 1..n) +---@field filter? fun(index: integer):boolean Filter callback for available options +---@field popupWidth? integer Width of the choice popup +---@field active? fun():boolean + +-- ========================================================================== +-- Slider +-- ========================================================================== + +--- Horizontal slider. +---@class LvglSliderSettings : LvglCommonSettings +---@field min? integer Default 0 +---@field max? integer Default 100 +---@field get? fun():integer +---@field set? fun(value: integer) Called on user interaction +---@field active? fun():boolean + +-- ========================================================================== +-- Setting (label + control container) +-- ========================================================================== + +--- A labeled row containing a title and child controls. +---@class LvglSettingSettings : LvglCommonSettings +---@field title? string|fun():string +---@field flexFlow? integer +---@field flexPad? integer +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Source selector +-- ========================================================================== + +--- Source picker control. +---@class LvglSourceSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field filter? integer Bitmask of SRC_* constants +---@field active? fun():boolean + +-- ========================================================================== +-- Switch selector +-- ========================================================================== + +--- Switch picker control. +---@class LvglSwitchSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field filter? integer Bitmask of SW_* constants +---@field active? fun():boolean + +-- ========================================================================== +-- File picker +-- ========================================================================== + +--- File selection control. +---@class LvglFileSettings : LvglCommonSettings +---@field title? string|fun():string +---@field folder? string Folder path +---@field extension? string File extension filter +---@field maxLen? integer Maximum filename length +---@field hideExtension? boolean Hide extension in display +---@field get? fun():string +---@field set? fun(value: string) +---@field active? fun():boolean + +-- ========================================================================== +-- Font picker +-- ========================================================================== + +--- Font selection control. +---@class LvglFontSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Align picker +-- ========================================================================== + +--- Alignment selection control. +---@class LvglAlignSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Color picker +-- ========================================================================== + +--- Color selection control. +---@class LvglColorSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Timer picker +-- ========================================================================== + +--- Timer selection control. +---@class LvglTimerSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Geometry primitives +-- ========================================================================== + +--- Horizontal line. +---@class LvglHlineSettings : LvglCommonSettings +---@field thickness? integer +---@field rounded? boolean Round end caps +---@field dashGap? integer Gap size for dashed lines +---@field dashWidth? integer Dash size for dashed lines + +--- Vertical line. +---@class LvglVlineSettings : LvglCommonSettings +---@field thickness? integer +---@field rounded? boolean Round end caps +---@field dashGap? integer +---@field dashWidth? integer + +--- One or more connected line segments. +---@class LvglLineSettings : LvglCommonSettings +---@field pts? table|fun():table Array of {x, y} points (at least 2) +---@field thickness? integer +---@field rounded? boolean Round end caps + +--- Filled triangle defined by 3 points. +---@class LvglTriangleSettings : LvglCommonSettings +---@field pts? table|fun():table Array of 3 {x, y} points + +--- Circle (outline or filled). +---@class LvglCircleSettings : LvglCommonSettings +---@field radius? integer|fun():integer +---@field filled? boolean|fun():boolean +---@field thickness? integer + +--- Arc segment with optional background arc. +---@class LvglArcSettings : LvglCommonSettings +---@field radius? integer|fun():integer +---@field thickness? integer +---@field rounded? boolean Round arc ends +---@field startAngle? integer|fun():integer Degrees (0=right/3 o'clock, clockwise) +---@field endAngle? integer|fun():integer +---@field bgColor? integer|fun():integer Background arc color +---@field bgOpacity? integer|fun():integer Background arc opacity (0-255) +---@field bgStartAngle? integer|fun():integer +---@field bgEndAngle? integer|fun():integer + +--- Display an image from the SD card. +---@class LvglImageSettings : LvglCommonSettings +---@field file? string|fun():string Full path to image file +---@field fill? boolean Scale to fill parent frame (may crop) +---@field active? fun():boolean + +--- Display a QR code. +---@class LvglQrcodeSettings : LvglCommonSettings +---@field data? string URL or content to encode +---@field bgColor? integer Background (light) color + +-- ========================================================================== +-- lvgl module +-- ========================================================================== + +---@class lvgl +lvgl = {} + +--- Remove all LVGL objects, or only children of a specific object. +---@param obj? LvglObject +function lvgl.clear(obj) end + +--- Build a widget tree from a declarative table. +---@param tree table[] +---@return table +function lvgl.build(tree) end + +--- Check if the current script is running in app mode (One-Time script). +---@return boolean +function lvgl.isAppMode() end + +--- Check if the widget is running in full-screen mode. +---@return boolean +function lvgl.isFullScreen() end + +--- Exit full-screen widget mode. +function lvgl.exitFullScreen() end + +--- Get the current widget context table. +---@return table context +function lvgl.getContext() end + +--- Update properties of an existing LVGL object. +---@param obj LvglObject +---@param settings table +function lvgl.set(obj, settings) end + +--- Show an LVGL object. +---@param obj LvglObject +function lvgl.show(obj) end + +--- Hide an LVGL object. +---@param obj LvglObject +function lvgl.hide(obj) end + +--- Enable an LVGL object (restore interactivity). +---@param obj LvglObject +function lvgl.enable(obj) end + +--- Disable an LVGL object (grey out, prevent interaction). +---@param obj LvglObject +function lvgl.disable(obj) end + +--- Close and destroy an LVGL object. +---@param obj LvglObject +function lvgl.close(obj) end + +--- Get the scroll position of an LVGL object. +---@param obj LvglObject +---@return integer x +---@return integer y +function lvgl.getScrollPos(obj) end + +-- Widget creation functions + +---@param settings LvglPageSettings +---@return LvglObject +function lvgl.page(settings) end + +---@param parent_or_settings LvglObject|LvglDialogSettings +---@param settings? LvglDialogSettings +---@return LvglObject +function lvgl.dialog(parent_or_settings, settings) end + +---@param settings LvglConfirmSettings +---@return LvglObject +function lvgl.confirm(settings) end + +---@param settings LvglMessageSettings +---@return LvglObject +function lvgl.message(settings) end + +---@param settings LvglMenuSettings +---@return LvglObject +function lvgl.menu(settings) end + +---@param parent_or_settings LvglObject|LvglLabelSettings +---@param settings? LvglLabelSettings +---@return LvglObject +function lvgl.label(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglRectangleSettings +---@param settings? LvglRectangleSettings +---@return LvglObject +function lvgl.rectangle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglBoxSettings +---@param settings? LvglBoxSettings +---@return LvglObject +function lvgl.box(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglButtonSettings +---@param settings? LvglButtonSettings +---@return LvglObject +function lvgl.button(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglMomentaryButtonSettings +---@param settings? LvglMomentaryButtonSettings +---@return LvglObject +function lvgl.momentaryButton(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglToggleSettings +---@param settings? LvglToggleSettings +---@return LvglObject +function lvgl.toggle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTextEditSettings +---@param settings? LvglTextEditSettings +---@return LvglObject +function lvgl.textEdit(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglNumberEditSettings +---@param settings? LvglNumberEditSettings +---@return LvglObject +function lvgl.numberEdit(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglChoiceSettings +---@param settings? LvglChoiceSettings +---@return LvglObject +function lvgl.choice(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSliderSettings +---@param settings? LvglSliderSettings +---@return LvglObject +function lvgl.slider(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSliderSettings +---@param settings? LvglSliderSettings +---@return LvglObject +function lvgl.verticalSlider(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSettingSettings +---@param settings? LvglSettingSettings +---@return LvglObject +function lvgl.setting(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSourceSettings +---@param settings? LvglSourceSettings +---@return LvglObject +function lvgl.source(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSwitchSettings +---@param settings? LvglSwitchSettings +---@return LvglObject +function lvgl.switch(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglFileSettings +---@param settings? LvglFileSettings +---@return LvglObject +function lvgl.file(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglFontSettings +---@param settings? LvglFontSettings +---@return LvglObject +function lvgl.font(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglAlignSettings +---@param settings? LvglAlignSettings +---@return LvglObject +function lvgl.align(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglColorSettings +---@param settings? LvglColorSettings +---@return LvglObject +function lvgl.color(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTimerSettings +---@param settings? LvglTimerSettings +---@return LvglObject +function lvgl.timer(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglHlineSettings +---@param settings? LvglHlineSettings +---@return LvglObject +function lvgl.hline(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglVlineSettings +---@param settings? LvglVlineSettings +---@return LvglObject +function lvgl.vline(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglLineSettings +---@param settings? LvglLineSettings +---@return LvglObject +function lvgl.line(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTriangleSettings +---@param settings? LvglTriangleSettings +---@return LvglObject +function lvgl.triangle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglCircleSettings +---@param settings? LvglCircleSettings +---@return LvglObject +function lvgl.circle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglArcSettings +---@param settings? LvglArcSettings +---@return LvglObject +function lvgl.arc(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglImageSettings +---@param settings? LvglImageSettings +---@return LvglObject +function lvgl.image(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglQrcodeSettings +---@param settings? LvglQrcodeSettings +---@return LvglObject +function lvgl.qrcode(parent_or_settings, settings) end + +-- ========================================================================== +-- LVGL constants +-- ========================================================================== + +-- Layout flow +---@type integer +lvgl.FLOW_ROW = 0 +---@type integer +lvgl.FLOW_COLUMN = 0 + +-- Padding presets +---@type integer +lvgl.PAD_TINY = 0 +---@type integer +lvgl.PAD_SMALL = 0 +---@type integer +lvgl.PAD_MEDIUM = 0 +---@type integer +lvgl.PAD_LARGE = 0 +---@type integer +lvgl.PAD_OUTLINE = 0 +---@type integer +lvgl.PAD_BORDER = 0 + +-- Source filter bitmask constants +---@type integer +lvgl.SRC_ALL = 0 +---@type integer +lvgl.SRC_INPUT = 0 +---@type integer +lvgl.SRC_LUA = 0 +---@type integer +lvgl.SRC_STICK = 0 +---@type integer +lvgl.SRC_POT = 0 +---@type integer +lvgl.SRC_OTHER = 0 +---@type integer +lvgl.SRC_HELI = 0 +---@type integer +lvgl.SRC_TRIM = 0 +---@type integer +lvgl.SRC_SWITCH = 0 +---@type integer +lvgl.SRC_LOGICAL_SWITCH = 0 +---@type integer +lvgl.SRC_TRAINER = 0 +---@type integer +lvgl.SRC_CHANNEL = 0 +---@type integer +lvgl.SRC_GVAR = 0 +---@type integer +lvgl.SRC_TELEM = 0 +---@type integer +lvgl.SRC_CLEAR = 0 +---@type integer +lvgl.SRC_INVERT = 0 + +-- Switch filter bitmask constants +---@type integer +lvgl.SW_ALL = 0 +---@type integer +lvgl.SW_SWITCH = 0 +---@type integer +lvgl.SW_TRIM = 0 +---@type integer +lvgl.SW_LOGICAL_SWITCH = 0 +---@type integer +lvgl.SW_TELEM = 0 +---@type integer +lvgl.SW_OTHER = 0 +---@type integer +lvgl.SW_CLEAR = 0 + +-- Scroll direction constants +---@type integer +lvgl.SCROLL_OFF = 0 +---@type integer +lvgl.SCROLL_HOR = 0 +---@type integer +lvgl.SCROLL_VER = 0 +---@type integer +lvgl.SCROLL_ALL = 0 + +-- Layout helper constants +---@type integer +lvgl.PERCENT_SIZE = 0 +---@type integer +lvgl.PAGE_BODY_HEIGHT = 0 +---@type integer +lvgl.UI_ELEMENT_HEIGHT = 0 +---@type number +lvgl.LCD_SCALE = 0.0 + +-- Widget type constants (for use in build() tables) +---@type integer +lvgl.LABEL = 0 +---@type integer +lvgl.RECTANGLE = 0 +---@type integer +lvgl.CIRCLE = 0 +---@type integer +lvgl.ARC = 0 +---@type integer +lvgl.HLINE = 0 +---@type integer +lvgl.VLINE = 0 +---@type integer +lvgl.LINE = 0 +---@type integer +lvgl.TRIANGLE = 0 +---@type integer +lvgl.IMAGE = 0 +---@type integer +lvgl.QRCODE = 0 +---@type integer +lvgl.BOX = 0 +---@type integer +lvgl.BUTTON = 0 +---@type integer +lvgl.MOMENTARY_BUTTON = 0 +---@type integer +lvgl.TOGGLE = 0 +---@type integer +lvgl.TEXT_EDIT = 0 +---@type integer +lvgl.NUMBER_EDIT = 0 +---@type integer Typo in C++ source, kept for compatibility +lvgl.CHOIDE = 0 +---@type integer +lvgl.CHOICE = 0 +---@type integer +lvgl.SLIDER = 0 +---@type integer +lvgl.VERTICAL_SLIDER = 0 +---@type integer +lvgl.PAGE = 0 +---@type integer +lvgl.FONT = 0 +---@type integer +lvgl.ALIGN = 0 +---@type integer +lvgl.COLOR = 0 +---@type integer +lvgl.TIMER = 0 +---@type integer +lvgl.SWITCH = 0 +---@type integer +lvgl.SOURCE = 0 +---@type integer +lvgl.FILE = 0 +---@type integer +lvgl.SETTING = 0 diff --git a/types/model.d.lua b/types/model.d.lua new file mode 100644 index 0000000..e78ec8b --- /dev/null +++ b/types/model.d.lua @@ -0,0 +1,352 @@ +---@meta + +-- EdgeTX model module +-- Source: edgetx/radio/src/lua/api_model.cpp + +---@class model +model = {} + +--- Get current model information. +--- @since 2.0.6 +---@return {name: string, extendedLimits: boolean, jitterFilter: integer, bitmap: string, labels: string, filename: string} +function model.getInfo() end + +--- Set current model information. Missing fields remain unchanged. +--- @since 2.0.6 +---@param info {name?: string, extendedLimits?: boolean, jitterFilter?: integer, bitmap?: string} +function model.setInfo(info) end + +--- Get RF module parameters. Returns nil if index is invalid. +--- @since 2.2.0 +---@param index integer Module slot (0=internal, 1=external) +---@return {subType: integer, modelId: integer, firstChannel: integer, channelsCount: integer, Type: integer, protocol?: integer, subProtocol?: integer, channelsOrder?: integer}|nil module +function model.getModule(index) end + +--- Set RF module parameters. Missing fields remain unchanged. +--- @since 2.2.0 +---@param index integer Module slot (0=internal, 1=external) +---@param config {Type?: integer, subType?: integer, modelId?: integer, firstChannel?: integer, channelsCount?: integer, protocol?: integer, subProtocol?: integer} +function model.setModule(index, config) end + +--- Get model timer parameters. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +---@return {mode: integer, start: integer, value: integer, countdownBeep: integer, minuteBeep: boolean, persistent: integer, name: string, showElapsed: boolean, switch: integer, countdownStart: integer, extraHaptic: integer}|nil timer +function model.getTimer(index) end + +--- Set model timer parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +---@param config {mode?: integer, start?: integer, value?: integer, countdownBeep?: integer, minuteBeep?: boolean, persistent?: integer, name?: string, showElapsed?: boolean, switch?: integer, countdownStart?: integer, extraHaptic?: integer} +function model.setTimer(index, config) end + +--- Reset model timer to its startup value. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +function model.resetTimer(index) end + +--- Clear all flight modes. +function model.deleteFlightModes() end + +--- Get flight mode data. +--- @since 2.1.7 +---@param index integer Flight mode index (0-based) +---@return {name: string, switch: integer, fadeIn: integer, fadeOut: integer, trimsValues: table, trimsModes: table} flightMode +function model.getFlightMode(index) end + +--- Set flight mode parameters. Missing fields remain unchanged. +--- @since 2.1.7 +---@param index integer Flight mode index (0-based) +---@param config {name?: string, switch?: integer, fadeIn?: integer, fadeOut?: integer, trimsValues?: table, trimsModes?: table} +---@return integer result 0=success, 2=invalid index +function model.setFlightMode(index, config) end + +--- Return the number of lines for the given input. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@return integer count +function model.getInputsCount(input) end + +--- Get input line configuration. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number within input (0-based) +---@return {name: string, inputName: string, source: integer, scale: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, trimSource: integer, side: integer, flightModes: integer} inputConfig +function model.getInput(input, line) end + +--- Insert an input line at the specified position. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number to insert at (0-based) +---@param config {name?: string, inputName?: string, source?: integer, scale?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, trimSource?: integer, side?: integer, flightModes?: integer} +function model.insertInput(input, line, config) end + +--- Delete a line from the specified input. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number (0-based) +function model.deleteInput(input, line) end + +--- Delete all input lines. +function model.deleteInputs() end + +--- Set all inputs to defaults (one stick per input). +function model.defaultInputs() end + +--- Get the number of mixer lines for the given channel. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@return integer count +function model.getMixesCount(channel) end + +--- Get mixer line configuration. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number within channel (0-based) +---@return {name: string, source: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, multiplex: integer, flightModes: integer, carryTrim: boolean, mixWarn: integer, delayPrec: integer, delayUp: integer, delayDown: integer, speedPrec: integer, speedUp: integer, speedDown: integer} mix +function model.getMix(channel, line) end + +--- Insert a mixer line at the specified position. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number to insert at (0-based) +---@param config {name?: string, source?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, multiplex?: integer, flightModes?: integer, carryTrim?: boolean, mixWarn?: integer, delayPrec?: integer, delayUp?: integer, delayDown?: integer, speedPrec?: integer, speedUp?: integer, speedDown?: integer} +function model.insertMix(channel, line, config) end + +--- Delete a mixer line from the specified channel. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number (0-based) +function model.deleteMix(channel, line) end + +--- Remove all mixer lines. +function model.deleteMixes() end + +--- Get logical switch parameters. +--- @since 2.0.0 +---@param index integer Logical switch index (0-based) +---@return {func: integer, v1: integer, v2: integer, v3: integer, ["and"]: integer, delay: integer, duration: integer, state: boolean, persistent: boolean} logicalSwitch +function model.getLogicalSwitch(index) end + +--- Set logical switch parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Logical switch index (0-based) +---@param config {func?: integer, v1?: integer, v2?: integer, v3?: integer, ["and"]?: integer, delay?: integer, duration?: integer, state?: boolean, persistent?: boolean} +function model.setLogicalSwitch(index, config) end + +--- Get custom (special) function parameters. +--- @since 2.0.0 +---@param index integer Function index (0-based) +---@return {switch: integer, func: integer, name?: string, value?: integer, mode?: integer, param?: integer, active: integer, repetition: integer} customFunction +function model.getCustomFunction(index) end + +--- Set custom (special) function parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Function index (0-based) +---@param config {switch?: integer, func?: integer, name?: string, value?: integer, mode?: integer, param?: integer, active?: integer, repetition?: integer} +function model.setCustomFunction(index, config) end + +--- Get curve parameters. The `y` table contains point values (1-based). +--- The `x` table is only present for custom curve types. +--- @since 2.0.12 +---@param index integer Curve index (0-based) +---@return {name: string, type: integer, smooth: boolean, points: integer, y: table, x?: table} curve +function model.getCurve(index) end + +--- Set curve parameters. +--- @since 2.0.12 +---@param index integer Curve index (0-based) +---@param config {name?: string, type?: integer, smooth?: boolean, points?: integer, y?: table, x?: table} +---@return integer result 0=success, 1=wrong number of points, 2=invalid curve number +function model.setCurve(index, config) end + +--- Get output (servo) channel parameters. Values are in 0.1% units. +--- @since 2.0.0 +---@param index integer Output channel index (0-based) +---@return {name: string, min: integer, max: integer, offset: integer, ppmCenter: integer, symetrical: integer, revert: integer, curve?: integer} output +function model.getOutput(index) end + +--- Set output (servo) channel parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Output channel index (0-based) +---@param config {name?: string, min?: integer, max?: integer, offset?: integer, ppmCenter?: integer, symetrical?: integer, revert?: integer, curve?: integer} +function model.setOutput(index, config) end + +--- Get the current value of a global variable. +--- @since 2.1.0 +---@param index integer GVar index (0-based) +---@param flightMode integer Flight mode index (0-based) +---@return integer value +function model.getGlobalVariable(index, flightMode) end + +--- Set the value of a global variable. +--- @since 2.1.0 +---@param index integer GVar index (0-based) +---@param flightMode integer Flight mode index (0-based) +---@param value integer +function model.setGlobalVariable(index, flightMode, value) end + +--- Get global variable details (name, range, unit, etc.). +---@param index integer GVar index (0-based) +---@return {name: string, min: integer, max: integer, prec: integer, unit: integer, popup: boolean}|nil details +function model.getGlobalVariableDetails(index) end + +--- Set global variable details. Missing fields remain unchanged. +---@param index integer GVar index (0-based) +---@param details {name?: string, min?: integer, max?: integer, prec?: integer, unit?: integer, popup?: boolean} +function model.setGlobalVariableDetails(index, details) end + +--- Get telemetry sensor parameters. The `id` and `instance` fields are +--- only present for custom sensors; `formula` is only present for calculated sensors. +--- @since 2.2.0 +---@param index integer Sensor index (0-based) +---@return {type: integer, name: string, unit: integer, prec: integer, id?: integer, instance?: integer, formula?: integer} sensor +function model.getSensor(index) end + +--- Reset telemetry sensor to default value. +---@param index integer Sensor index (0-based) +function model.resetSensor(index) end + +--- Get heli swash ring parameters. Only available on builds with HELI support. +---@return {type: integer, value: integer, collectiveSource: integer, aileronSource: integer, elevatorSource: integer, collectiveWeight: integer, aileronWeight: integer, elevatorWeight: integer} swashRing +function model.getSwashRing() end + +--- Set heli swash ring parameters. Missing fields remain unchanged. +--- Only available on builds with HELI support. +---@param config {type?: integer, value?: integer, collectiveSource?: integer, aileronSource?: integer, elevatorSource?: integer, collectiveWeight?: integer, aileronWeight?: integer, elevatorWeight?: integer} +function model.setSwashRing(config) end + +-- ========================================================================== +-- Global model-related functions +-- ========================================================================== + +--- Return trainer connection status. +---@return integer status +function getTrainerStatus() end + +-- ========================================================================== +-- Input / source type constants +-- ========================================================================== + +---@type integer +VALUE = 0 +---@type integer +SOURCE = 0 +---@type integer +REPLACE = 0 +---@type integer +MIXSRC_MIN = 0 +---@type integer +MIXSRC_MAX = 0 +---@type integer +MIXSRC_FIRST_INPUT = 0 +---@type integer +MIXSRC_CH1 = 0 +---@type integer +SWSRC_LAST = 0 +---@type integer +SWITCH_COUNT = 0 +---@type integer +MAX_SENSORS = 0 +---@type integer +MAX_OUTPUT_CHANNELS = 0 +---@type integer +LIMIT_EXT_PERCENT = 0 +---@type integer +LIMIT_STD_PERCENT = 0 + +-- ========================================================================== +-- Logical switch function constants +-- ========================================================================== + +---@type integer +LS_FUNC_NONE = 0 +---@type integer +LS_FUNC_VEQUAL = 0 +---@type integer +LS_FUNC_VALMOSTEQUAL = 0 +---@type integer +LS_FUNC_VPOS = 0 +---@type integer +LS_FUNC_VNEG = 0 +---@type integer +LS_FUNC_APOS = 0 +---@type integer +LS_FUNC_ANEG = 0 +---@type integer +LS_FUNC_AND = 0 +---@type integer +LS_FUNC_OR = 0 +---@type integer +LS_FUNC_XOR = 0 +---@type integer +LS_FUNC_EDGE = 0 +---@type integer +LS_FUNC_EQUAL = 0 +---@type integer +LS_FUNC_GREATER = 0 +---@type integer +LS_FUNC_LESS = 0 +---@type integer +LS_FUNC_DIFFEGREATER = 0 +---@type integer +LS_FUNC_ADIFFEGREATER = 0 +---@type integer +LS_FUNC_TIMER = 0 +---@type integer +LS_FUNC_STICKY = 0 + +-- ========================================================================== +-- Custom function constants +-- ========================================================================== + +---@type integer +FUNC_OVERRIDE_CHANNEL = 0 +---@type integer +FUNC_TRAINER = 0 +---@type integer +FUNC_INSTANT_TRIM = 0 +---@type integer +FUNC_RESET = 0 +---@type integer +FUNC_SET_TIMER = 0 +---@type integer +FUNC_ADJUST_GVAR = 0 +---@type integer +FUNC_VOLUME = 0 +---@type integer +FUNC_SET_FAILSAFE = 0 +---@type integer +FUNC_RANGECHECK = 0 +---@type integer +FUNC_BIND = 0 +---@type integer +FUNC_PLAY_SOUND = 0 +---@type integer +FUNC_PLAY_TRACK = 0 +---@type integer +FUNC_PLAY_VALUE = 0 +---@type integer +FUNC_PLAY_SCRIPT = 0 +---@type integer +FUNC_BACKGND_MUSIC = 0 +---@type integer +FUNC_BACKGND_MUSIC_PAUSE = 0 +---@type integer +FUNC_VARIO = 0 +---@type integer +FUNC_HAPTIC = 0 +---@type integer +FUNC_LOGS = 0 +---@type integer +FUNC_BACKLIGHT = 0 +---@type integer +FUNC_SCREENSHOT = 0 +---@type integer +FUNC_RACING_MODE = 0 +---@type integer +FUNC_PUSH_CUST_SWITCH = 0 +---@type integer +FUNC_SET_SCREEN = 0 +---@type integer +FUNC_DISABLE_TOUCH = 0 diff --git a/types/runtime.d.lua b/types/runtime.d.lua new file mode 100644 index 0000000..af45be6 --- /dev/null +++ b/types/runtime.d.lua @@ -0,0 +1,22 @@ +---@meta + +-- EdgeTX Lua runtime introspection functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Load and compile a Lua script from the SD card. +--- Returns the compiled chunk or nil + error message. +--- @since 2.2.0 +---@param path? string Script file path +---@param mode? string Compilation mode +---@param env? table Custom environment table +---@return function|nil chunk +---@return string|nil errmsg +function loadScript(path, mode, env) end + +--- Return Lua memory usage as a percentage (0-100). +---@return integer percent +function getUsage() end + +--- Return available Lua memory in bytes. +---@return integer bytes +function getAvailableMemory() end diff --git a/types/serial.d.lua b/types/serial.d.lua new file mode 100644 index 0000000..96d4564 --- /dev/null +++ b/types/serial.d.lua @@ -0,0 +1,28 @@ +---@meta + +-- EdgeTX serial port communication functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Set the serial port baud rate for Lua serial communication. +---@param baudrate integer Baud rate (e.g. 115200) +function setSerialBaudrate(baudrate) end + +--- Write a string to the serial port. +---@param data string Bytes to send +function serialWrite(data) end + +--- Read bytes from the serial port. Returns an empty string if no data available. +---@param num? integer Maximum number of bytes to read (0 = all available) +---@return string data +function serialRead(num) end + +--- Get external serial port power state. +---@param port_nr integer Port number +---@return boolean|nil power true/false for power state, nil if unavailable +function serialGetPower(port_nr) end + +--- Set external serial port power state. +---@param port_nr integer Port number +---@param power integer Power value (0=off, 1=on) +---@return boolean success +function serialSetPower(port_nr, power) end diff --git a/types/telemetry.d.lua b/types/telemetry.d.lua new file mode 100644 index 0000000..8e6050f --- /dev/null +++ b/types/telemetry.d.lua @@ -0,0 +1,180 @@ +---@meta + +-- EdgeTX telemetry protocol functions and sensor unit constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Push telemetry data via ACCESS protocol (PXX2). When called without +--- parameters, returns the output buffer availability. +---@overload fun(): boolean +---@param module integer Module number (-1 for default) +---@param rxUid integer Receiver UID +---@param sensorId integer Physical sensor ID +---@param frameId integer Frame type +---@param dataId integer Data ID +---@param value integer Data value +---@return boolean success +function accessTelemetryPush(module, rxUid, sensorId, frameId, dataId, value) end + +--- Pop a received S.PORT telemetry frame. Returns nil if queue is empty. +--- @since 2.0.0 +---@return integer|nil physicalId +---@return integer|nil primId +---@return integer|nil dataId +---@return integer|nil value +function sportTelemetryPop() end + +--- Push an S.PORT telemetry frame. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.0.0 +---@overload fun(): boolean +---@param sensor integer Sensor physical ID +---@param frame integer Frame type +---@param dataId integer Data ID +---@param value integer Data value +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function sportTelemetryPush(sensor, frame, dataId, value) end + +--- Set a telemetry sensor value. Automatically creates the sensor if needed. +--- @since 2.2.0 +---@param id integer Sensor ID +---@param subId integer Sub-ID (0-7) +---@param instance integer Instance +---@param value integer Sensor value +---@param unit? integer Unit constant (UNIT_*, default 0) +---@param precision? integer Decimal precision (default 0) +---@param name? string Sensor name (up to 4 chars, auto-generated from ID if omitted) +---@return boolean success +function setTelemetryValue(id, subId, instance, value, unit, precision, name) end + +--- Pop a received Crossfire telemetry packet. Returns nil if queue is empty. +--- @since 2.2.0 +---@return integer|nil command +---@return integer[]|nil data Array of payload bytes +function crossfireTelemetryPop() end + +--- Push a Crossfire telemetry packet. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.2.0 +---@overload fun(): boolean +---@param command integer CRSF command byte +---@param data integer[] Array of payload bytes +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function crossfireTelemetryPush(command, data) end + +--- Pop a received Ghost telemetry packet. Returns nil if queue is empty. +--- @since 2.7.0 +---@return integer|nil type +---@return integer[]|nil data Array of payload bytes +function ghostTelemetryPop() end + +--- Push a Ghost telemetry packet. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.7.0 +---@overload fun(): boolean +---@param type integer Ghost frame type +---@param data integer[] Array of payload bytes (max 10) +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function ghostTelemetryPush(type, data) end + +--- Read or write the Multi module shared buffer. When called with two +--- arguments, writes `value` at `index`. Always returns the current value. +---@param index integer Buffer index (0-based) +---@param value? integer Value to write (omit to just read; internal sentinel is 0x100) +---@return integer value Current buffer value at index +function multiBuffer(index, value) end + +--- Return RSSI value and alarm thresholds. +--- @since 2.0.0 +---@return integer rssi Current RSSI +---@return integer alarm_low Low alarm threshold +---@return integer alarm_crit Critical alarm threshold +function getRSSI() end + +--- Return RSSI Alarm Status (RAS) or nil if not available. +---@return integer|nil value +function getRAS() end + +--- Return built-in GPS data or nil if no GPS present. +---@return table|nil gpsData +function getTxGPS() end + +-- ========================================================================== +-- Unit constants +-- ========================================================================== + +---@type integer +UNIT_RAW = 0 +---@type integer +UNIT_VOLTS = 0 +---@type integer +UNIT_AMPS = 0 +---@type integer +UNIT_MILLIAMPS = 0 +---@type integer +UNIT_KTS = 0 +---@type integer +UNIT_METERS_PER_SECOND = 0 +---@type integer +UNIT_FEET_PER_SECOND = 0 +---@type integer +UNIT_KMH = 0 +---@type integer +UNIT_MPH = 0 +---@type integer +UNIT_METERS = 0 +---@type integer +UNIT_KM = 0 +---@type integer +UNIT_FEET = 0 +---@type integer +UNIT_CELSIUS = 0 +---@type integer +UNIT_FAHRENHEIT = 0 +---@type integer +UNIT_PERCENT = 0 +---@type integer +UNIT_MAH = 0 +---@type integer +UNIT_WATTS = 0 +---@type integer +UNIT_MILLIWATTS = 0 +---@type integer +UNIT_DB = 0 +---@type integer +UNIT_RPMS = 0 +---@type integer +UNIT_G = 0 +---@type integer +UNIT_DEGREE = 0 +---@type integer +UNIT_RADIANS = 0 +---@type integer +UNIT_MILLILITERS = 0 +---@type integer +UNIT_FLOZ = 0 +---@type integer +UNIT_MILLILITERS_PER_MINUTE = 0 +---@type integer +UNIT_HERTZ = 0 +---@type integer +UNIT_MS = 0 +---@type integer +UNIT_US = 0 +---@type integer +UNIT_DBM = 0 +---@type integer +UNIT_HOURS = 0 +---@type integer +UNIT_MINUTES = 0 +---@type integer +UNIT_SECONDS = 0 +---@type integer +UNIT_CELLS = 0 +---@type integer +UNIT_DATETIME = 0 +---@type integer +UNIT_GPS = 0 +---@type integer +UNIT_BITFIELD = 0 +---@type integer +UNIT_TEXT = 0 diff --git a/types/time.d.lua b/types/time.d.lua new file mode 100644 index 0000000..1b94d06 --- /dev/null +++ b/types/time.d.lua @@ -0,0 +1,19 @@ +---@meta + +-- EdgeTX time and clock functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Return the time since the radio was started in multiples of 10ms. +--- Uses a 32-bit counter (enough for 497 days). +--- @since 2.0.0 +---@return integer timestamp 10ms tick count +function getTime() end + +--- Return current system date and time from the RTC. +--- @since 2.0.0 +---@return {year: integer, mon: integer, day: integer, hour: integer, min: integer, sec: integer, hour12: integer, suffix: string} +function getDateTime() end + +--- Return current RTC time as a Unix timestamp (seconds since epoch). +---@return integer seconds Unix timestamp from RTC +function getRtcTime() end diff --git a/types/widget.d.lua b/types/widget.d.lua new file mode 100644 index 0000000..eec8db7 --- /dev/null +++ b/types/widget.d.lua @@ -0,0 +1,43 @@ +---@meta + +-- EdgeTX widget shared memory and option type constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +-- ========================================================================== +-- Shared memory (color LCD only) +-- ========================================================================== + +--- Set a shared memory variable (for inter-widget communication). Color LCD only. +---@param index integer Variable index +---@param value integer +function setShmVar(index, value) end + +--- Get a shared memory variable value. Color LCD only. +---@param index integer Variable index +---@return integer|nil value nil if index is invalid +function getShmVar(index) end + +-- ========================================================================== +-- Widget option type constants +-- ========================================================================== + +---@type integer +COLOR = 0 +---@type integer +BOOL = 0 +---@type integer +STRING = 0 +---@type integer +TIMER = 0 +---@type integer +TEXT_SIZE = 0 +---@type integer +ALIGNMENT = 0 +---@type integer +SWITCH = 0 +---@type integer +SLIDER = 0 +---@type integer +CHOICE = 0 +---@type integer +FILE = 0 From 3ca475c39be4ba9eec316aa62f4b7ce3175c553b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 18 Feb 2026 13:25:23 +0200 Subject: [PATCH 051/218] stylua setup --- .stylua.toml | 7 + src/SCRIPTS/ELRS/crsf.lua | 175 ++++-- src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 144 +++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 45 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 147 +++-- src/WIDGETS/ELRSTelemetry/loadable.lua | 366 ++++++------ src/WIDGETS/ELRSTelemetry/ui/hd.lua | 32 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 32 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 32 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 32 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 32 +- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 4 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 445 ++++++++------ src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 40 +- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 89 +-- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 38 +- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 38 +- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 37 +- src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 6 +- test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 582 +++++++++++++------ 21 files changed, 1417 insertions(+), 908 deletions(-) create mode 100644 .stylua.toml diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..cee468f --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,7 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 263a0cf..6d39e42 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -22,42 +22,42 @@ local CRSF = {} CRSF.CONST = { -- Addresses - ADDRESS_TX_MODULE = 0xEE, - ADDRESS_HANDSET = 0xEF, - ADDRESS_BROADCAST = 0x00, + ADDRESS_TX_MODULE = 0xEE, + ADDRESS_HANDSET = 0xEF, + ADDRESS_BROADCAST = 0x00, ADDRESS_RADIO_TRANSMITTER = 0xEA, -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses) - FIELD_UINT8 = 0, - FIELD_INT8 = 1, - FIELD_UINT16 = 2, - FIELD_INT16 = 3, - FIELD_FLOAT = 8, - FIELD_TEXT_SELECTION = 9, - FIELD_STRING = 10, - FIELD_FOLDER = 11, - FIELD_INFO = 12, - FIELD_COMMAND = 13, + FIELD_UINT8 = 0, + FIELD_INT8 = 1, + FIELD_UINT16 = 2, + FIELD_INT16 = 3, + FIELD_FLOAT = 8, + FIELD_TEXT_SELECTION = 9, + FIELD_STRING = 10, + FIELD_FOLDER = 11, + FIELD_INFO = 12, + FIELD_COMMAND = 13, -- Command states - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, - CMD_CONFIRMED = 3, + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_CONFIRMED = 3, -- Folder child list terminator - FIELD_LIST_END = 0xFF, + FIELD_LIST_END = 0xFF, -- Module type for model.getModule() check - MODULE_TYPE_CROSSFIRE = 5, + MODULE_TYPE_CROSSFIRE = 5, } -- ============================================================================ @@ -241,8 +241,7 @@ function CRSF:requestDeviceInfo() return end self._lastDevPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, - { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER }) + CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER }) end --- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). @@ -254,8 +253,7 @@ function CRSF:requestElrsStatus() return end self._lastStatusPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, - { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) + CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) end -- ============================================================================ @@ -283,33 +281,116 @@ local function onDeviceInfo(data) if info.vMaj == 4 then -- selene: allow(mixed_table) info.RFMOD = { - "25Hz", "50Hz", "100Hz", "100HzFull", "150Hz", "200Hz", "200HzFull", - "250Hz", "333HzFull", "500Hz", "D50", "K1000Full", - [21]="25Hz", [22]="50Hz", [23]="100Hz", [24]="100HzFull", - [25]="150Hz", [26]="200Hz", [27]="200HzFull", [28]="250Hz", - [29]="333HzFull", [30]="500Hz", - [31]="D250", [32]="D500", [33]="F500", [34]="F1000", - [35]="DK250", [36]="DK500", [37]="K1000", - [101]="X100Full", [102]="X150", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "200HzFull", + "250Hz", + "333HzFull", + "500Hz", + "D50", + "K1000Full", + [21] = "25Hz", + [22] = "50Hz", + [23] = "100Hz", + [24] = "100HzFull", + [25] = "150Hz", + [26] = "200Hz", + [27] = "200HzFull", + [28] = "250Hz", + [29] = "333HzFull", + [30] = "500Hz", + [31] = "D250", + [32] = "D500", + [33] = "F500", + [34] = "F1000", + [35] = "DK250", + [36] = "DK500", + [37] = "K1000", + [101] = "X100Full", + [102] = "X150", } -- selene: allow(mixed_table) info.RFRSSI = { - -123, -120, -117, -112, 0, -112, -111, -111, 0, 0, -112, -101, - [21]=0, [22]=-115, [23]=0, [24]=-112, [25]=-112, [26]=0, [27]=0, - [28]=-108, [29]=-105, [30]=-105, - [31]=-104, [32]=-104, [33]=-104, [34]=-104, - [35]=-103, [36]=-103, [37]=-103, - [101]=-112, [102]=-112, + -123, + -120, + -117, + -112, + 0, + -112, + -111, + -111, + 0, + 0, + -112, + -101, + [21] = 0, + [22] = -115, + [23] = 0, + [24] = -112, + [25] = -112, + [26] = 0, + [27] = 0, + [28] = -108, + [29] = -105, + [30] = -105, + [31] = -104, + [32] = -104, + [33] = -104, + [34] = -104, + [35] = -103, + [36] = -103, + [37] = -103, + [101] = -112, + [102] = -112, } elseif info.vMaj == 3 then info.RFMOD = { - "", "25Hz", "50Hz", "100Hz", "100HzFull", "150Hz", "200Hz", "250Hz", - "333HzFull", "500Hz", "D250", "D500", "F500", "F1000", - "D50", "200HzFull", "DK500", "K1000", "9K1000", "K1000Full", + "", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "250Hz", + "333HzFull", + "500Hz", + "D250", + "D500", + "F500", + "F1000", + "D50", + "200HzFull", + "DK500", + "K1000", + "9K1000", + "K1000Full", } info.RFRSSI = { - 0, -123, -115, -117, -112, -112, -112, -108, -105, -105, - -104, -104, -104, -104, -112, -111, -103, -103, 0, -101, + 0, + -123, + -115, + -117, + -112, + -112, + -112, + -108, + -105, + -105, + -104, + -104, + -104, + -104, + -112, + -111, + -103, + -103, + 0, + -101, } else info.RFMOD = { "", "25Hz", "50Hz", "100Hz", "150Hz", "200Hz", "250Hz", "500Hz" } diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua index 77690d4..25e834b 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -14,7 +14,7 @@ local Navigation = { function Navigation.getCurrent() local top = Navigation.stack[#Navigation.stack] - return top and top.id or nil -- nil if at root (or device root) + return top and top.id or nil -- nil if at root (or device root) end function Navigation.isAtRoot() diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index fbdc5ef..fb5d6a3 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -11,65 +11,65 @@ local Protocol = { -- CRSF Field Type Constants CRSF = { - UINT8 = 0, - INT8 = 1, - UINT16 = 2, - INT16 = 3, - UINT32 = 4, - INT32 = 5, - UINT64 = 6, - INT64 = 7, - FLOAT = 8, + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + UINT32 = 4, + INT32 = 5, + UINT64 = 6, + INT64 = 7, + FLOAT = 8, TEXT_SELECTION = 9, - STRING = 10, - FOLDER = 11, - INFO = 12, - COMMAND = 13, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, -- Internal/extended types (not in official CRSF protocol) - BACK_EXIT = 14, - DEVICE = 15, - DEVICE_FOLDER = 16, + BACK_EXIT = 14, + DEVICE = 15, + DEVICE_FOLDER = 16, -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, -- Addresses - ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, -- ELRS identification - ELRS_SERIAL_ID = 0x454C5253, + ELRS_SERIAL_ID = 0x454C5253, -- ELRS flags: bits 0-1 are status (connected, status1), -- bits 2-4 are warnings (model match, armed, warning1), -- bits 5-7 are critical errors (error connected, error baudrate, critical2) - ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only + ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, CMD_ASKCONFIRM = 3, - CMD_CONFIRMED = 4, - CMD_CANCEL = 5, - CMD_QUERY = 6, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, }, -- Handlers dispatch table (populated after function definitions) handlers = {}, -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua - deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) - handsetId = 0xEF, -- ADDRESS_ELRS_LUA + deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) + handsetId = 0xEF, -- ADDRESS_ELRS_LUA deviceName = nil, deviceIsELRS_TX = nil, @@ -151,7 +151,10 @@ function Protocol.push(command, data) end function Protocol.pingDevices() - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_DEVICE_PING, + { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER } + ) end -- Check connection state from elrsFlags @@ -191,7 +194,8 @@ function Protocol.setDevice(device) Protocol.deviceName = device.name Protocol.fieldsCount = device.fieldCount Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA + or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER Protocol.allocateFields() Protocol.reloadAllFields() @@ -204,7 +208,7 @@ end function Protocol.allocateFields() Protocol.fields = {} - Protocol.fields[0] = {} -- root folder (field 0) + Protocol.fields[0] = {} -- root folder (field 0) for i = 1, Protocol.fieldsCount do Protocol.fields[i] = {} end @@ -408,7 +412,7 @@ function Protocol.fieldFloatLoad(field, data, offset) field.prec = 3 end field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = shim.tableConcat({"%.", tostring(field.prec), "f", field.unit or ""}) + field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f", field.unit or "" }) field.prec = 10 ^ field.prec end @@ -492,9 +496,11 @@ function Protocol.reloadRelatedFields(field) for fieldId = Protocol.fieldsCount, 1, -1 do local sibling = Protocol.fields[fieldId] local siblingType = sibling.type or 99 - if fieldId ~= field.id + if + fieldId ~= field.id and sibling.parent == field.parent - and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then + and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) + then sibling.dirty = true sibling.name = nil Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId @@ -514,7 +520,10 @@ function Protocol.handleCommandSave(field) if field.status ~= nil then if field.status < Protocol.CRSF.CMD_CONFIRMED then field.status = Protocol.CRSF.CMD_CLICK - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, field.id, field.status } + ) Protocol.fieldPopup = field Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE Protocol.fieldTimeout = getTime() + field.timeout @@ -524,7 +533,10 @@ end function Protocol.commandConfirm() if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED } + ) Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED end @@ -532,7 +544,10 @@ end function Protocol.commandCancel() if Protocol.fieldPopup then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + ) Protocol.fieldPopup = nil end end @@ -542,20 +557,20 @@ end -- ============================================================================ Protocol.handlers = { - [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT32 + 1] = nil, - [Protocol.CRSF.INT32 + 1] = nil, - [Protocol.CRSF.UINT64 + 1] = nil, - [Protocol.CRSF.INT64 + 1] = nil, - [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT32 + 1] = nil, + [Protocol.CRSF.INT32 + 1] = nil, + [Protocol.CRSF.UINT64 + 1] = nil, + [Protocol.CRSF.INT64 + 1] = nil, + [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, - [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, } -- ============================================================================ @@ -630,8 +645,7 @@ function Protocol.parseParameterInfoMessage(data) end -- Auto-queue children for root folder (field 0) and during background preloading. - if field.type == Protocol.CRSF.FOLDER and field.children - and (fieldId == 0 or Protocol.backgroundLoading) then + if field.type == Protocol.CRSF.FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then for i = #field.children, 1, -1 do Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] end @@ -719,7 +733,10 @@ function Protocol.tick() if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY } + ) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end elseif time > Protocol.linkstatTimeout then @@ -732,7 +749,10 @@ function Protocol.tick() Protocol.linkstatTimeout = time + 100 elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then if #Protocol.loadQueue > 0 then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_READ, + { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk } + ) Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() else Protocol.backgroundLoading = false diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 133dbf0..d585cbc 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -352,18 +352,18 @@ local function fieldCommandDisplay(field, y, attr) end local displayHandlers = {} -displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay -displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay -displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay -displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay -displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay -displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay +displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay +displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay +displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay +displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay +displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay +displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay -- ============================================================================ -- Title bar drawing @@ -428,8 +428,7 @@ function UI.handleEvent(event) elseif event == EVT_VIRTUAL_ENTER then if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then Protocol.elrsFlags = 0 - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) elseif UI.isOnBackExit() then if Navigation.isAtRoot() then App.shouldExit = true @@ -521,12 +520,16 @@ end function UI.drawPopup(event) if event == EVT_VIRTUAL_EXIT then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL }) + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + ) Protocol.fieldTimeout = getTime() + 200 end - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + if + Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE + then popupConfirmation(Protocol.fieldPopup.info or "", "Stopped!", event) Protocol.reloadAllFields() Protocol.fieldPopup = nil @@ -543,9 +546,13 @@ function UI.drawPopup(event) UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end local result = popupConfirmation( - (Protocol.fieldPopup.info or "") .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", + (Protocol.fieldPopup.info or "") + .. " [" + .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) + .. "]", "Press [RTN] to exit", - event) + event + ) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "CANCEL" then Protocol.commandCancel() diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index eecf56d..c12ef31 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -25,7 +25,7 @@ local UI = { warningDismissed = false, warningDismissedAt = nil, warningDialog = nil, - commandDialog = nil + commandDialog = nil, } -- ============================================================================ @@ -60,7 +60,7 @@ function ModelMismatchDialog.show(onContinue, onExit) local dg = lvgl.dialog({ title = "Model Mismatch", flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL + flexPad = lvgl.PAD_SMALL, }) dg:build({ @@ -120,7 +120,7 @@ function NoModuleDialog.show(onExit) title = "No Module Found: Check Model Settings", flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_SMALL, - close = onExit + close = onExit, }) dg:build({ @@ -174,7 +174,7 @@ local function createSpinner(parent) flexPad = lvgl.PAD_MEDIUM, color = COLOR_THEME_PRIMARY2, w = lvgl.PERCENT_SIZE + 100, - align = CENTER + align = CENTER, }) wrapper:arc({ radius = r, @@ -360,22 +360,41 @@ local function showVersionRequired() title = "EdgeTX Version Not Supported", flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_SMALL, - close = function() App.shouldExit = true end + close = function() + App.shouldExit = true + end, }) dg:build({ - {type="box", x=10, flexFlow=lvgl.FLOW_COLUMN, flexPad=lvgl.PAD_SMALL, children={ - {type="label", text="Requires EdgeTX:"}, - {type="label", text="- 2.11.5 or later"}, - {type="label", text="- 2.12-rc4 or later"}, - {type="label", text="- 3.0 or later"}, - }}, - {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}, - }}, + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "Requires EdgeTX:" }, + { type = "label", text = "- 2.11.5 or later" }, + { type = "label", text = "- 2.12-rc4 or later" }, + { type = "label", text = "- 3.0 or later" }, + }, + }, + { + 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, + }, + }, + }, }) end @@ -497,7 +516,9 @@ function UI.handleBack() Dialogs.showConfirm({ title = "Exit", message = "Exit ExpressLRS Lua script?", - onConfirm = function() App.shouldExit = true end + onConfirm = function() + App.shouldExit = true + end, }) else local entry = App.goBack() @@ -530,27 +551,24 @@ local function handleCommandPopup() return end - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE then + if + Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE + then Protocol.reloadAllFields() Protocol.fieldPopup = nil UI.commandDialog = nil UI.invalidate() elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then - UI.commandDialog = CommandPage.showConfirm( - Protocol.fieldPopup.name, - Protocol.fieldPopup.info, - function() Protocol.commandConfirm() end, - onCommandCancel - ) + UI.commandDialog = CommandPage.showConfirm(Protocol.fieldPopup.name, Protocol.fieldPopup.info, function() + Protocol.commandConfirm() + end, onCommandCancel) end Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then - UI.commandDialog = CommandPage.showExecuting( - Protocol.fieldPopup.name or Protocol.fieldPopup.info, - onCommandCancel - ) + UI.commandDialog = + CommandPage.showExecuting(Protocol.fieldPopup.name or Protocol.fieldPopup.info, onCommandCancel) end Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status end @@ -567,21 +585,18 @@ local function handleWarning() if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then if not UI.warningDialog and not UI.warningDismissed then if Protocol.elrsFlagsInfo == "Model Mismatch" then - UI.warningDialog = ModelMismatchDialog.show( - function() - UI.warningDismissed = true - UI.warningDismissedAt = getTime() - UI.invalidate() - end, - function() - UI.warningDismissed = true - App.shouldExit = true - end - ) + UI.warningDialog = ModelMismatchDialog.show(function() + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + UI.invalidate() + end, function() + UI.warningDismissed = true + App.shouldExit = true + end) else Dialogs.showMessage({ title = "Warning", - message = Protocol.elrsFlagsInfo + message = Protocol.elrsFlagsInfo, }) UI.warningDialog = true UI.warningDismissed = true @@ -635,7 +650,7 @@ end function UI.getSubtitle() if not Navigation.isAtRoot() then local top = Navigation.stack[#Navigation.stack] - local subtitleParts = {top.name or ""} + local subtitleParts = { top.name or "" } local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) if loaded and loaded < total then @@ -656,9 +671,13 @@ function UI.getSubtitle() subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) end - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then + if + Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK + and Protocol.elrsFlagsInfo + and Protocol.elrsFlagsInfo ~= "" + then if subtitle ~= "" then - subtitle = table.concat({subtitle, " • ", Protocol.elrsFlagsInfo}) + subtitle = table.concat({ subtitle, " • ", Protocol.elrsFlagsInfo }) else subtitle = Protocol.elrsFlagsInfo end @@ -726,13 +745,17 @@ function UI.createToggleRow(pg, field) children = { { type = lvgl.TOGGLE, - get = function() return field.value or 0 end, + get = function() + return field.value or 0 + end, set = function(val) field.value = val Protocol.fieldIntSave(field) Protocol.reloadRelatedFields(field) end, - active = function() return not field.disabled end, + active = function() + return not field.disabled + end, }, { type = lvgl.BOX, @@ -777,13 +800,17 @@ function UI.createChoiceRow(pg, field) { type = lvgl.CHOICE, values = filteredValues, - get = function() return origToFiltered[field.value or 0] or 1 end, + get = function() + return origToFiltered[field.value or 0] or 1 + end, set = function(val) field.value = filteredToOrig[val] or 0 Protocol.fieldIntSave(field) Protocol.reloadRelatedFields(field) end, - active = function() return not field.disabled end, + active = function() + return not field.disabled + end, }, { type = lvgl.BOX, @@ -814,7 +841,9 @@ function UI.createNumberRow(pg, field) x = LABEL_PCT, min = field.min or 0, max = field.max or 255, - get = function() return field.value or 0 end, + get = function() + return field.value or 0 + end, set = function(val) field.value = val end, @@ -827,9 +856,11 @@ function UI.createNumberRow(pg, field) if field.type == Protocol.CRSF.FLOAT then return string.format(field.fmt or "%.0f", val / (field.prec or 1)) end - return table.concat({tostring(val), field.unit or ""}) + return table.concat({ tostring(val), field.unit or "" }) + end, + active = function() + return not field.disabled end, - active = function() return not field.disabled end, }, }, }, @@ -860,7 +891,7 @@ function UI.createFolderWidget(pg, field, width) h = lvgl.UI_ELEMENT_HEIGHT * 2, press = function() UI.openFolder(field.id, field.name) - end + end, }) end @@ -870,7 +901,7 @@ function UI.createCommandWidget(pg, field) w = lvgl.PERCENT_SIZE + 100, press = function() Protocol.handleCommandSave(field) - end + end, }) end @@ -915,7 +946,7 @@ function UI.build() local pageOptions = { title = "ExpressLRS", - subtitle = UI.getSubtitle + subtitle = UI.getSubtitle, } if not Navigation.isAtRoot() then @@ -945,7 +976,7 @@ function UI.build() w = lvgl.PERCENT_SIZE + 100, press = function() UI.switchDevice(device.id) - end + end, }) end end @@ -985,7 +1016,7 @@ function UI.build() flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_SMALL, align = CENTER, - color = COLOR_THEME_PRIMARY2 + color = COLOR_THEME_PRIMARY2, }) for k = 0, FOLDERS_PER_ROW - 1 do @@ -1013,7 +1044,7 @@ function UI.build() h = lvgl.UI_ELEMENT_HEIGHT * 2, press = function() UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") - end + end, }) end end @@ -1021,7 +1052,7 @@ function UI.build() fieldContainer:rectangle({ w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, - thickness = 0 + thickness = 0, }) UI.uiBuilt = true diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 60629ce..6d6d986 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -22,9 +22,9 @@ Telemetry = { smoothRng = nil, -- Cell count detection state - cellCnt = nil, + cellCnt = nil, cellCntCnt = 0, - cellLastV = nil, + cellLastV = nil, -- Diversity detection isDiversity = false, @@ -73,12 +73,12 @@ end --- Read all link telemetry values into a table. function Telemetry.readLink() return { - tpwr = crsf.getSensorValue("TPWR"), - rfmd = crsf.getSensorValue("RFMD"), + tpwr = crsf.getSensorValue("TPWR"), + rfmd = crsf.getSensorValue("RFMD"), rssi1 = crsf.getSensorValue("1RSS"), rssi2 = crsf.getSensorValue("2RSS"), - rqly = crsf.getSensorValue("RQly"), - ant = crsf.getSensorValue("ANT"), + rqly = crsf.getSensorValue("RQly"), + ant = crsf.getSensorValue("ANT"), } end @@ -87,7 +87,6 @@ function Telemetry.hasModule() return crsf.hasCrsfModule() end - --- Short status text when not operational or warning active. --- Returns nil when connected with no warnings. --- Used by both full-screen and minimized UIs. @@ -129,7 +128,7 @@ end --- Get RF mode string from device info. function Telemetry.getRfModeStr(rfmd) local mod = crsf.deviceInfo - return (mod.RFMOD and mod.RFMOD[(rfmd or 0) + 1]) or table.concat({"RFMD", tostring(rfmd or 0)}) + return (mod.RFMOD and mod.RFMOD[(rfmd or 0) + 1]) or table.concat({ "RFMD", tostring(rfmd or 0) }) end --- Update GPS cache from telemetry. @@ -149,14 +148,20 @@ end --- Pick the active antenna's RSSI value from a readLink() result. function Telemetry.getRssi(tlm) - if not tlm then return nil end + if not tlm then + return nil + end return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 end --- Map range percentage to a warning color. function Telemetry.rangeColor(pct) - if pct > 90 then return RED end - if pct > 70 then return ORANGE end + if pct > 90 then + return RED + end + if pct > 70 then + return ORANGE + end return COLOR_THEME_SECONDARY1 end @@ -167,10 +172,10 @@ function Telemetry.signalText() end local tlm = Telemetry.readLink() local pct = Telemetry.getRangePct(tlm) - local parts = { table.concat({"Range ", tostring(pct), "%"}) } + local parts = { table.concat({ "Range ", tostring(pct), "%" }) } local rssi = Telemetry.getRssi(tlm) if rssi then - parts[#parts + 1] = table.concat({tostring(rssi), "dBm"}) + parts[#parts + 1] = table.concat({ tostring(rssi), "dBm" }) end return table.concat(parts, " ") end @@ -181,7 +186,7 @@ function Telemetry.rfDetailText() local mode = Telemetry.getRfModeStr(tlm.rfmd) local parts = { mode } if crsf.rxConnected and tlm.tpwr then - parts[#parts + 1] = table.concat({tostring(tlm.tpwr), "mW"}) + parts[#parts + 1] = table.concat({ tostring(tlm.tpwr), "mW" }) end return table.concat(parts, " ") end @@ -254,15 +259,15 @@ end local function getScreenId() local w, h = LCD_W, LCD_H if w >= 800 then - return "hd" -- 800x480 + return "hd" -- 800x480 elseif w < h then return "portrait" -- 320x480 (EL18) elseif w <= 320 then - return "small" -- 320x240 + return "small" -- 320x240 elseif h >= 320 then - return "sd_tall" -- 480x320 (TX16S) + return "sd_tall" -- 480x320 (TX16S) else - return "sd" -- 480x272 + return "sd" -- 480x272 end end @@ -273,7 +278,7 @@ local function bgOpacity(opts) end local screenId = getScreenId() -local uiPath = table.concat({"/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua"}) +local uiPath = table.concat({ "/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ crsf = crsf, Telemetry = Telemetry, @@ -295,10 +300,20 @@ local function createDisplayRow(container, label, valueFn, colorFn) flexFlow = lvgl.FLOW_ROW, flexPad = 0, children = { - { type = lvgl.LABEL, text = label, color = COLOR_THEME_PRIMARY1, - w = lvgl.PERCENT_SIZE + LABEL_PCT, y = lvgl.PAD_SMALL }, - { type = lvgl.LABEL, text = valueFn, color = colorFn or COLOR_THEME_SECONDARY1, - w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), y = lvgl.PAD_SMALL }, + { + type = lvgl.LABEL, + text = label, + color = COLOR_THEME_PRIMARY1, + w = lvgl.PERCENT_SIZE + LABEL_PCT, + y = lvgl.PAD_SMALL, + }, + { + type = lvgl.LABEL, + text = valueFn, + color = colorFn or COLOR_THEME_SECONDARY1, + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + y = lvgl.PAD_SMALL, + }, }, }) end @@ -341,7 +356,9 @@ local function buildFullScreen() end return "Telemetry" end, - back = function() lvgl.exitFullScreen() end, + back = function() + lvgl.exitFullScreen() + end, }) -- No module — show checklist instead of telemetry (matches expresslrs.lua NoModuleDialog) @@ -355,7 +372,11 @@ local function buildFullScreen() { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, - { type = lvgl.LABEL, text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, }, }) return @@ -374,187 +395,172 @@ local function buildFullScreen() font = BOLD, color = RED, text = "Model Mismatch — RC commands not sent", - visible = function() return crsf.modelMismatch end, + visible = function() + return crsf.modelMismatch + end, }, }) -- Link Status section createSectionHeader(fields, "Link Status") - createDisplayRow(fields, "RF Mode", - function() - local tlm = Telemetry.readLink() - return Telemetry.getRfModeStr(tlm.rfmd) - end) + createDisplayRow(fields, "RF Mode", function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end) - createDisplayRow(fields, "Link Quality", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - return table.concat({tostring(tlm.rqly or 0), "%"}) - end) + createDisplayRow(fields, "Link Quality", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + return table.concat({ tostring(tlm.rqly or 0), "%" }) + end) - createDisplayRow(fields, "RSSI 1", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - if tlm.rssi1 == nil then - return "--" - end - return table.concat({tostring(tlm.rssi1), " dBm"}) - end) + createDisplayRow(fields, "RSSI 1", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi1 == nil then + return "--" + end + return table.concat({ tostring(tlm.rssi1), " dBm" }) + end) - createDisplayRow(fields, "RSSI 2", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - if tlm.rssi2 == nil then - return "--" - end - return table.concat({tostring(tlm.rssi2), " dBm"}) - end, - function() - if not Telemetry.isDiversity then - return COLOR_THEME_DISABLED - end - return COLOR_THEME_SECONDARY1 - end) + createDisplayRow(fields, "RSSI 2", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi2 == nil then + return "--" + end + return table.concat({ tostring(tlm.rssi2), " dBm" }) + end, function() + if not Telemetry.isDiversity then + return COLOR_THEME_DISABLED + end + return COLOR_THEME_SECONDARY1 + end) - createDisplayRow(fields, "Active Antenna", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - if not Telemetry.isDiversity then - return "N/A" - end - return (tlm.ant == 1) and "2" or "1" - end) + createDisplayRow(fields, "Active Antenna", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if not Telemetry.isDiversity then + return "N/A" + end + return (tlm.ant == 1) and "2" or "1" + end) - createDisplayRow(fields, "Range", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - local pct = Telemetry.getRangePct(tlm) - return table.concat({tostring(pct), "%"}) - end) + createDisplayRow(fields, "Range", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + local pct = Telemetry.getRangePct(tlm) + return table.concat({ tostring(pct), "%" }) + end) -- Power section createSectionHeader(fields, "Power") - createDisplayRow(fields, "TX Power", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - if tlm.tpwr == nil then - return "--" - end - return table.concat({tostring(tlm.tpwr), " mW"}) - end) + createDisplayRow(fields, "TX Power", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return table.concat({ tostring(tlm.tpwr), " mW" }) + end) - createDisplayRow(fields, "Power Index", - function() - if not crsf.rxConnected then - return "--" - end - local tlm = Telemetry.readLink() - if tlm.tpwr == nil then - return "--" - end - return tostring(Telemetry.pwrToIdx(tlm.tpwr)) - end) + createDisplayRow(fields, "Power Index", function() + if not crsf.rxConnected then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return tostring(Telemetry.pwrToIdx(tlm.tpwr)) + end) -- Flight Controller section createSectionHeader(fields, "Flight Controller") - createDisplayRow(fields, "Battery", - function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "--" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) - end - return string.format("%.2fV", vbat) - end) - - createDisplayRow(fields, "Current", - function() - local curr = crsf.getSensorValue("Curr") - if curr == nil or curr <= 0 then - return "--" - end - return string.format("%.2f A", curr) - end) - - createDisplayRow(fields, "Flight Mode", - function() - local fm = crsf.getSensorValue("FM") - if fm == nil or fm == 0 then - return "--" - end - return tostring(fm) - end) + createDisplayRow(fields, "Battery", function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "--" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) + end + return string.format("%.2fV", vbat) + end) + + createDisplayRow(fields, "Current", function() + local curr = crsf.getSensorValue("Curr") + if curr == nil or curr <= 0 then + return "--" + end + return string.format("%.2f A", curr) + end) + + createDisplayRow(fields, "Flight Mode", function() + local fm = crsf.getSensorValue("FM") + if fm == nil or fm == 0 then + return "--" + end + return tostring(fm) + end) -- GPS section createSectionHeader(fields, "GPS") - createDisplayRow(fields, "Satellites", - function() - local sats = crsf.getSensorValue("Sats") - if sats == nil then - return "--" - end - return tostring(sats) - end) - - createDisplayRow(fields, "Speed", - function() - local gspd = crsf.getSensorValue("GSpd") - if gspd == nil then - return "--" - end - return string.format("%.1f", gspd) - end) - - createDisplayRow(fields, "Altitude", - function() - local alt = crsf.getSensorValue("Alt") - if alt == nil then - return "--" - end - return tostring(alt) - end) + createDisplayRow(fields, "Satellites", function() + local sats = crsf.getSensorValue("Sats") + if sats == nil then + return "--" + end + return tostring(sats) + end) - createDisplayRow(fields, "Latitude", - function() - if Telemetry.gps == nil then - return "--" - end - return tostring(Telemetry.gps.lat) - end) + createDisplayRow(fields, "Speed", function() + local gspd = crsf.getSensorValue("GSpd") + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) - createDisplayRow(fields, "Longitude", - function() - if Telemetry.gps == nil then - return "--" - end - return tostring(Telemetry.gps.lon) - end) + createDisplayRow(fields, "Altitude", function() + local alt = crsf.getSensorValue("Alt") + if alt == nil then + return "--" + end + return tostring(alt) + end) + + createDisplayRow(fields, "Latitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lat) + end) + + createDisplayRow(fields, "Longitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lon) + end) end -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 913e5a7..a1eaaae 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -13,17 +13,17 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 800x480. WidgetUI.breakpoints = { - topBarW = 200, - sixthH = 74, + topBarW = 200, + sixthH = 74, quarterH = 104, - thirdH = 146, + thirdH = 146, } WidgetUI.fonts = { - sixth = { hero = BOLD }, + sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = DBLSIZE, detail = 0 }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = DBLSIZE, detail = 0 }, } -- ============================================================================ @@ -50,7 +50,7 @@ local function heroTextLq() return status end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end -- ============================================================================ @@ -58,7 +58,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, Telemetry = Telemetry, + crsf = crsf, + Telemetry = Telemetry, }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). @@ -271,11 +272,16 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 265427e..4655938 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -13,17 +13,17 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x480 portrait. WidgetUI.breakpoints = { - topBarW = 80, - sixthH = 55, + topBarW = 80, + sixthH = 55, quarterH = 78, - thirdH = 110, + thirdH = 110, } WidgetUI.fonts = { - sixth = { hero = BOLD }, + sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -50,7 +50,7 @@ local function heroTextLq() return status end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end -- ============================================================================ @@ -58,7 +58,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, Telemetry = Telemetry, + crsf = crsf, + Telemetry = Telemetry, }) --- 1/6: single line — LQ (bold) + Range/dBm (colored). @@ -241,11 +242,16 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index f86f8a3..d496bb1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -13,17 +13,17 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x272. WidgetUI.breakpoints = { - topBarW = 100, - sixthH = 37, + topBarW = 100, + sixthH = 37, quarterH = 52, - thirdH = 73, + thirdH = 73, } WidgetUI.fonts = { - sixth = { hero = BOLD }, + sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -50,7 +50,7 @@ local function heroTextLq() return status end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end -- ============================================================================ @@ -58,7 +58,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, Telemetry = Telemetry, + crsf = crsf, + Telemetry = Telemetry, }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). @@ -258,11 +259,16 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index d52bbb1..5dbe406 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -14,17 +14,17 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x320. -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { - topBarW = 100, - sixthH = 44, + topBarW = 100, + sixthH = 44, quarterH = 62, - thirdH = 86, + thirdH = 86, } WidgetUI.fonts = { - sixth = { hero = BOLD }, + sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -51,7 +51,7 @@ local function heroTextLq() return status end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end -- ============================================================================ @@ -59,7 +59,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, Telemetry = Telemetry, + crsf = crsf, + Telemetry = Telemetry, }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). @@ -270,11 +271,16 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 0366429..b15f2bc 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -14,17 +14,17 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x240. -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { - topBarW = 80, - sixthH = 30, + topBarW = 80, + sixthH = 30, quarterH = 42, - thirdH = 58, + thirdH = 58, } WidgetUI.fonts = { - sixth = { hero = BOLD }, + sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } -- ============================================================================ @@ -51,7 +51,7 @@ local function heroTextLq() return status end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end -- ============================================================================ @@ -59,7 +59,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, Telemetry = Telemetry, + crsf = crsf, + Telemetry = Telemetry, }) --- 1/6: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). @@ -257,11 +258,16 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 85fc389..b29c8e2 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -47,7 +47,7 @@ function TopBarUI.build(w, h) return "--" end local tlm = Telemetry.readLink() - return table.concat({"LQ ", tostring(tlm.rqly or 0), "%"}) + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) end, }, { @@ -72,7 +72,7 @@ function TopBarUI.build(w, h) if rssi == nil then return "" end - return table.concat({tostring(rssi), "dBm"}) + return table.concat({ tostring(rssi), "dBm" }) end, }, }, diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 7f7832b..6e2e569 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -23,33 +23,33 @@ local Presets VTX = { -- Band name lookup tables -- selene: allow(mixed_table) - BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, + BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, -- Field IDs (discovered at runtime) ids = { - folder = nil, - band = nil, + folder = nil, + band = nil, channel = nil, - power = nil, + power = nil, pitmode = nil, - send = nil, + send = nil, }, -- Current VTX state (parsed from folder name) state = { - band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L + band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L bandLetter = "?", - channel = 0, - power = 0, - pitmode = false, + channel = 0, + power = 0, + pitmode = false, }, -- Desired VTX state (edited by user in full-screen UI) desired = { - band = 5, -- Raceband + band = 5, -- Raceband channel = 1, - power = 0, + power = 0, pitmode = 0, }, } @@ -77,10 +77,10 @@ function VTX.parseFolderName(name) end s.bandLetter = parts[1] - s.band = VTX.BAND_VALUES[parts[1]] or 0 - s.channel = tonumber(parts[2]) or 0 - s.power = tonumber(parts[3]) or 0 - s.pitmode = (parts[#parts] == "P") + s.band = VTX.BAND_VALUES[parts[1]] or 0 + s.channel = tonumber(parts[2]) or 0 + s.power = tonumber(parts[3]) or 0 + s.pitmode = (parts[#parts] == "P") return true end @@ -88,9 +88,9 @@ end function VTX.syncDesiredFromState() local s = VTX.state local d = VTX.desired - d.band = s.band + d.band = s.band d.channel = s.channel - d.power = s.power + d.power = s.power d.pitmode = s.pitmode and 1 or 0 end @@ -100,33 +100,33 @@ end Protocol = { -- State machine constants - STATE_INIT = 0, - STATE_NO_MODULE = 1, - STATE_DISCOVER_ROOT = 2, + STATE_INIT = 0, + STATE_NO_MODULE = 1, + STATE_DISCOVER_ROOT = 2, STATE_DISCOVER_CHILDREN = 3, - STATE_DISCOVER_VTX = 4, - STATE_READY = 5, - STATE_SENDING = 6, + STATE_DISCOVER_VTX = 4, + STATE_READY = 5, + STATE_SENDING = 6, -- Current state - state = 0, -- STATE_INIT + state = 0, -- STATE_INIT statusText = "Initializing...", -- Discovery - loadQueue = {}, - rootChildren = {}, - vtxChildren = {}, - fieldTimeout = 0, + loadQueue = {}, + rootChildren = {}, + vtxChildren = {}, + fieldTimeout = 0, discoveredFields = {}, -- Write queue - writeQueue = {}, - writeIdx = 0, + writeQueue = {}, + writeIdx = 0, lastWriteTime = 0, -- Folder re-read timer - lastFolderPoll = 0, - FOLDER_POLL_INTERVAL = 200, -- 2 seconds + lastFolderPoll = 0, + FOLDER_POLL_INTERVAL = 200, -- 2 seconds } -- State query helpers @@ -158,7 +158,7 @@ function Protocol.parseChildIds(data) while data[off] ~= nil and data[off] ~= 0 do off = off + 1 end - off = off + 1 -- skip null terminator + off = off + 1 -- skip null terminator while data[off] ~= nil and data[off] ~= crsf.CONST.FIELD_LIST_END do ids[#ids + 1] = data[off] off = off + 1 @@ -190,13 +190,17 @@ end -- ============================================================================ function Protocol.sendParameterRead(fieldId) - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, - { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, 0 }) + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_READ, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, 0 } + ) end function Protocol.sendParameterWrite(fieldId, value) - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, value }) + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, value } + ) end --- Request ELRS_STATUS (connection state) by writing field 0. @@ -213,7 +217,7 @@ function Protocol.onSettingsEntry(data) return end - local fieldId = data[3] + local fieldId = data[3] local fieldType = Protocol.parseFieldType(data) local fieldName = Protocol.parseFieldName(data) @@ -236,7 +240,6 @@ function Protocol.onSettingsEntry(data) Protocol.state = Protocol.STATE_DISCOVER_CHILDREN Protocol.statusText = "Discovering fields..." end - elseif st == Protocol.STATE_DISCOVER_CHILDREN then if fieldType == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then VTX.ids.folder = fieldId @@ -251,13 +254,17 @@ function Protocol.onSettingsEntry(data) if #Protocol.loadQueue == 0 and VTX.ids.folder == nil then Protocol.statusText = "VTX Admin not found" end - elseif st == Protocol.STATE_DISCOVER_VTX then - if fieldName == "Band" then VTX.ids.band = fieldId - elseif fieldName == "Channel" then VTX.ids.channel = fieldId - elseif fieldName == "Pwr Lvl" then VTX.ids.power = fieldId - elseif fieldName == "Pitmode" then VTX.ids.pitmode = fieldId - elseif fieldName == "Send VTx" then VTX.ids.send = fieldId + if fieldName == "Band" then + VTX.ids.band = fieldId + elseif fieldName == "Channel" then + VTX.ids.channel = fieldId + elseif fieldName == "Pwr Lvl" then + VTX.ids.power = fieldId + elseif fieldName == "Pitmode" then + VTX.ids.pitmode = fieldId + elseif fieldName == "Send VTx" then + VTX.ids.send = fieldId end if #Protocol.loadQueue == 0 then @@ -269,7 +276,6 @@ function Protocol.onSettingsEntry(data) Protocol.statusText = "VTX fields incomplete" end end - elseif st == Protocol.STATE_READY then if fieldId == VTX.ids.folder then VTX.parseFolderName(fieldName) @@ -286,7 +292,7 @@ crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, Protocol.onS function Protocol.tick() local now = getTime() - local st = Protocol.state + local st = Protocol.state if st == Protocol.STATE_INIT then if crsf.hasCrsfModule() then @@ -298,34 +304,33 @@ function Protocol.tick() Protocol.state = Protocol.STATE_NO_MODULE Protocol.statusText = "No CRSF module" end - - elseif st == Protocol.STATE_DISCOVER_ROOT - or st == Protocol.STATE_DISCOVER_CHILDREN - or st == Protocol.STATE_DISCOVER_VTX then + elseif + st == Protocol.STATE_DISCOVER_ROOT + or st == Protocol.STATE_DISCOVER_CHILDREN + or st == Protocol.STATE_DISCOVER_VTX + then if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then local fieldId = Protocol.loadQueue[#Protocol.loadQueue] Protocol.sendParameterRead(fieldId) Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() end - elseif st == Protocol.STATE_READY then if now - Protocol.lastFolderPoll >= Protocol.FOLDER_POLL_INTERVAL then Protocol.lastFolderPoll = now Protocol.sendParameterRead(VTX.ids.folder) Protocol.sendStatusPoll() end - elseif st == Protocol.STATE_SENDING then if Protocol.writeIdx <= #Protocol.writeQueue then - if now - Protocol.lastWriteTime >= 5 then -- 50ms + if now - Protocol.lastWriteTime >= 5 then -- 50ms local entry = Protocol.writeQueue[Protocol.writeIdx] - print(table.concat({"VTXAdmin: writing field=", entry[1], " val=", entry[2]})) + print(table.concat({ "VTXAdmin: writing field=", entry[1], " val=", entry[2] })) Protocol.sendParameterWrite(entry[1], entry[2]) Protocol.lastWriteTime = now Protocol.writeIdx = Protocol.writeIdx + 1 end else - print(table.concat({"VTXAdmin: write queue complete, ", #Protocol.writeQueue, " entries sent"})) + print(table.concat({ "VTXAdmin: write queue complete, ", #Protocol.writeQueue, " entries sent" })) Protocol.writeQueue = {} Protocol.writeIdx = 0 Protocol.state = Protocol.STATE_READY @@ -350,10 +355,26 @@ function Protocol.writeConfig() local d = VTX.desired Protocol.writeQueue = {} - print(table.concat({"VTXAdmin: writeConfig() desired: band=", d.band, " ch=", d.channel, - " pwr=", d.power, " pit=", tostring(d.pitmode)})) - print(table.concat({"VTXAdmin: writeConfig() current: band=", s.band, " ch=", s.channel, - " pwr=", s.power, " pit=", tostring(s.pitmode)})) + print(table.concat({ + "VTXAdmin: writeConfig() desired: band=", + d.band, + " ch=", + d.channel, + " pwr=", + d.power, + " pit=", + tostring(d.pitmode), + })) + print(table.concat({ + "VTXAdmin: writeConfig() current: band=", + s.band, + " ch=", + s.channel, + " pwr=", + s.power, + " pit=", + tostring(s.pitmode), + })) if d.band ~= s.band then Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.band, d.band } @@ -374,7 +395,7 @@ function Protocol.writeConfig() Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.pitmode, desiredPit } end - print(table.concat({"VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)"})) + print(table.concat({ "VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)" })) if #Protocol.writeQueue > 0 then Protocol.writeIdx = 1 @@ -408,17 +429,17 @@ Presets = { PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt", -- Preset data - items = {}, - enabled = false, - source = 0, -- 6POS source ID (0 = not configured) - autoPushVtx = false, -- auto push to VTX on 6POS change - pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) + items = {}, + enabled = false, + source = 0, -- 6POS source ID (0 = not configured) + autoPushVtx = false, -- auto push to VTX on 6POS change + pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) -- 6POS processing state - lastPos = -1, - stablePos = -1, - stableTime = 0, - DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + lastPos = -1, + stablePos = -1, + stableTime = 0, + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) -- Push source edge detection state pushLastVal = -1, @@ -444,8 +465,7 @@ local function splitBandChannel(val) if not comma then return nil, nil end - return tonumber(string.sub(val, 1, comma - 1)), - tonumber(string.sub(val, comma + 1)) + return tonumber(string.sub(val, 1, comma - 1)), tonumber(string.sub(val, comma + 1)) end --- File format: key=value lines (one per line). @@ -503,34 +523,42 @@ function Presets.load() p[i] = { band = 5, channel = i } end end - Presets.items = p - Presets.enabled = enabled - Presets.source = source + Presets.items = p + Presets.enabled = enabled + Presets.source = source Presets.autoPushVtx = autoPushVtx - Presets.pushSource = pushSource - - print(table.concat({"VTXAdmin: presets loaded - enabled=", tostring(enabled), - " source=", source, " autoPushVtx=", tostring(autoPushVtx), " pushSource=", pushSource})) + Presets.pushSource = pushSource + + print(table.concat({ + "VTXAdmin: presets loaded - enabled=", + tostring(enabled), + " source=", + source, + " autoPushVtx=", + tostring(autoPushVtx), + " pushSource=", + pushSource, + })) for i = 1, 6 do - print(table.concat({"VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel})) + print(table.concat({ "VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel })) end end function Presets.save() - print(table.concat({"VTXAdmin: saving presets to ", Presets.PATH})) + print(table.concat({ "VTXAdmin: saving presets to ", Presets.PATH })) local f = io.open(Presets.PATH, "w") if f then - io.write(f, table.concat({"enabled=", Presets.enabled and "1" or "0", "\n"})) - io.write(f, table.concat({"source=", Presets.source, "\n"})) - io.write(f, table.concat({"autoPushVtx=", Presets.autoPushVtx and "1" or "0", "\n"})) - io.write(f, table.concat({"pushSource=", Presets.pushSource, "\n"})) + io.write(f, table.concat({ "enabled=", Presets.enabled and "1" or "0", "\n" })) + io.write(f, table.concat({ "source=", Presets.source, "\n" })) + io.write(f, table.concat({ "autoPushVtx=", Presets.autoPushVtx and "1" or "0", "\n" })) + io.write(f, table.concat({ "pushSource=", Presets.pushSource, "\n" })) for i = 1, 6 do - io.write(f, table.concat({"p", i, "=", Presets.items[i].band, ",", Presets.items[i].channel, "\n"})) + io.write(f, table.concat({ "p", i, "=", Presets.items[i].band, ",", Presets.items[i].channel, "\n" })) end io.close(f) print("VTXAdmin: presets saved OK") else - print(table.concat({"VTXAdmin: ERROR - could not open ", Presets.PATH, " for writing"})) + print(table.concat({ "VTXAdmin: ERROR - could not open ", Presets.PATH, " for writing" })) end end @@ -587,13 +615,13 @@ function Presets.process() if preset and preset.band > 0 then VTX.desired.band = preset.band VTX.desired.channel = preset.channel - print(table.concat({"VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel})) + print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel })) Protocol.writeConfig() if Presets.autoPushVtx then Protocol.pushToVtx() end else - print(table.concat({"VTXAdmin: 6POS pos=", pos, " -> Off (skipped)"})) + print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> Off (skipped)" })) end end @@ -707,7 +735,7 @@ function VTXDisplay.bandChannel() if not Protocol.isActive() or VTX.state.band == 0 then return "" end - return table.concat({VTX.state.bandLetter, VTX.state.channel}) + return table.concat({ VTX.state.bandLetter, VTX.state.channel }) end --- Short status message for non-VTX states, "" when VTX is tuned. @@ -725,47 +753,66 @@ function VTXDisplay.statusText() end function VTXDisplay.detailLine() - if not Protocol.isActive() then return "" end - if VTX.state.band == 0 then return "" end - local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "" + end + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" - return table.concat({pwr, pit}) + return table.concat({ pwr, pit }) end function VTXDisplay.powerShort() - if not Protocol.isActive() or VTX.state.band == 0 then return "" end - return VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" end function VTXDisplay.detailLong() - if not Protocol.isActive() then return "" end - if VTX.state.band == 0 then return "VTX Disabled" end - local pwr = VTX.state.power > 0 and table.concat({"Power ", VTX.state.power}) or "Power -" + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" - return table.concat({pwr, pit}) + return table.concat({ pwr, pit }) end function VTXDisplay.mainColor() - if VTX.state.pitmode then return RED end + if VTX.state.pitmode then + return RED + end return COLOR_THEME_PRIMARY1 end function VTXDisplay.build6posLabels() - if Protocol.state == Protocol.STATE_NO_MODULE then return {} end - if not Presets.enabled then return {} end + if Protocol.state == Protocol.STATE_NO_MODULE then + return {} + end + if not Presets.enabled then + return {} + end local labels = {} for i = 1, 6 do local idx = i labels[#labels + 1] = { - type = lvgl.LABEL, font = SMLSIZE, + type = lvgl.LABEL, + font = SMLSIZE, color = function() return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED end, text = function() local p = Presets.items[idx] local band = VTX.BAND_NAMES[p.band] or "?" - if band == "Off" then return table.concat({idx, ":Off"}) end - return table.concat({idx, ":", band, p.channel}) + if band == "Off" then + return table.concat({ idx, ":Off" }) + end + return table.concat({ idx, ":", band, p.channel }) end, } end @@ -774,14 +821,18 @@ end function VTXDisplay.buildCheatsheet() local labels = VTXDisplay.build6posLabels() - if #labels == 0 then return nil end + if #labels == 0 then + return nil + end return { type = lvgl.BOX, flexFlow = lvgl.FLOW_ROW, borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, - visible = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end, + visible = function() + return Protocol.state ~= Protocol.STATE_NO_MODULE + end, children = labels, } end @@ -794,15 +845,15 @@ end local function getScreenId() local w, h = LCD_W, LCD_H if w >= 800 then - return "hd" -- 800x480 + return "hd" -- 800x480 elseif w < h then return "portrait" -- 320x480 (EL18) elseif w <= 320 then - return "small" -- 320x240 + return "small" -- 320x240 elseif h >= 320 then - return "sd_tall" -- 480x320 (TX16S) + return "sd_tall" -- 480x320 (TX16S) else - return "sd" -- 480x272 + return "sd" -- 480x272 end end @@ -813,7 +864,7 @@ local function bgOpacity(opts) end local screenId = getScreenId() -local uiPath = table.concat({"/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua"}) +local uiPath = table.concat({ "/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ crsf = crsf, VTX = VTX, @@ -844,7 +895,10 @@ local function createRow(container, label, hint) } if hint then labelChildren[#labelChildren + 1] = { - type = lvgl.LABEL, text = hint, color = COLOR_THEME_DISABLED, font = SMLSIZE, + type = lvgl.LABEL, + text = hint, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, w = lvgl.PERCENT_SIZE + 100, } end @@ -879,7 +933,8 @@ end local function createNumberRow(container, label, min, max, getFn, setFn, editedFn, displayFn) local ctrl = createRow(container, label) ctrl:numberEdit({ - min = min, max = max, + min = min, + max = max, get = getFn, set = setFn, edited = editedFn, @@ -909,8 +964,13 @@ local function createHintRow(container, text) w = lvgl.PERCENT_SIZE + 100, thickness = 0, children = { - { type = lvgl.LABEL, text = text, color = COLOR_THEME_DISABLED, font = SMLSIZE, - w = lvgl.PERCENT_SIZE + 100 }, + { + type = lvgl.LABEL, + text = text, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + }, }, }) end @@ -949,7 +1009,9 @@ local function buildFullScreen() end return Protocol.statusText end, - back = function() lvgl.exitFullScreen() end, + back = function() + lvgl.exitFullScreen() + end, }) -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) @@ -963,7 +1025,11 @@ local function buildFullScreen() { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, - { type = lvgl.LABEL, text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, }, }) return @@ -978,39 +1044,39 @@ local function buildFullScreen() -- VTX Settings section createSectionHeader(fields, "VTX Settings") - createChoiceRow(fields, "Band", - { "Off", "A", "B", "E", "F", "R", "L" }, - function() return d.band + 1 end, - function(idx) - d.band = idx - 1 - Protocol.writeConfig() - end) - - createNumberRow(fields, "Channel", 1, 8, - function() return d.channel end, - function(v) d.channel = v end, - function(v) - d.channel = v - Protocol.writeConfig() - end) - - createNumberRow(fields, "Power Level", 0, 8, - function() return d.power end, - function(v) d.power = v end, - function(v) - d.power = v - Protocol.writeConfig() - end, - function(v) - return v == 0 and "-" or tostring(v) - end) - - createToggleRow(fields, "Pit Mode", - function() return d.pitmode end, - function(v) - d.pitmode = v - Protocol.writeConfig() - end) + createChoiceRow(fields, "Band", { "Off", "A", "B", "E", "F", "R", "L" }, function() + return d.band + 1 + end, function(idx) + d.band = idx - 1 + Protocol.writeConfig() + end) + + createNumberRow(fields, "Channel", 1, 8, function() + return d.channel + end, function(v) + d.channel = v + end, function(v) + d.channel = v + Protocol.writeConfig() + end) + + createNumberRow(fields, "Power Level", 0, 8, function() + return d.power + end, function(v) + d.power = v + end, function(v) + d.power = v + Protocol.writeConfig() + end, function(v) + return v == 0 and "-" or tostring(v) + end) + + createToggleRow(fields, "Pit Mode", function() + return d.pitmode + end, function(v) + d.pitmode = v + Protocol.writeConfig() + end) fields:button({ text = function() @@ -1024,43 +1090,49 @@ local function buildFullScreen() Protocol.writeConfig() Protocol.pushToVtx() end, - active = function() return Protocol.isReady() end, + active = function() + return Protocol.isReady() + end, }) -- 6POS Quick Change section createSectionHeader(fields, "6POS Quick Change") - createToggleRow(fields, "Enabled", - function() return Presets.enabled and 1 or 0 end, - function(v) - Presets.enabled = (v == 1) - Presets.save() - end) - - createSourceRow(fields, "Source", - function() return Presets.source end, - function(v) - Presets.source = v or 0 - Presets.save() + createToggleRow(fields, "Enabled", function() + return Presets.enabled and 1 or 0 + end, function(v) + Presets.enabled = (v == 1) + Presets.save() + end) + + createSourceRow(fields, "Source", function() + return Presets.source + end, function(v) + Presets.source = v or 0 + Presets.save() + end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) + + createToggleRow(fields, "Auto Push to VTX", function() + return Presets.autoPushVtx and 1 or 0 + end, function(v) + Presets.autoPushVtx = (v == 1) + Presets.save() + end) + + createSourceRow( + fields, + "Send VTx Trigger", + function() + return Presets.pushSource end, - lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) - - createToggleRow(fields, "Auto Push to VTX", - function() return Presets.autoPushVtx and 1 or 0 end, - function(v) - Presets.autoPushVtx = (v == 1) - Presets.save() - end) - - createSourceRow(fields, "Send VTx Trigger", - function() return Presets.pushSource end, function(v) Presets.pushSource = v or 0 Presets.pushLastVal = -1 Presets.save() end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, - "Assign a switch or button to manually push the current VTX config to the receiver.") + "Assign a switch or button to manually push the current VTX config to the receiver." + ) -- Presets section createSectionHeader(fields, "Presets") @@ -1070,11 +1142,13 @@ local function buildFullScreen() local bandValues = { "Off", "A", "B", "E", "F", "R", "L" } for i = 1, 6 do local idx = i - local ctrl = createRow(fields, table.concat({"Preset ", idx})) + local ctrl = createRow(fields, table.concat({ "Preset ", idx })) ctrl:choice({ values = bandValues, - get = function() return Presets.items[idx].band + 1 end, + get = function() + return Presets.items[idx].band + 1 + end, set = function(v) Presets.items[idx].band = v - 1 Presets.save() @@ -1082,13 +1156,18 @@ local function buildFullScreen() }) ctrl:numberEdit({ - min = 1, max = 8, - get = function() return Presets.items[idx].channel end, + min = 1, + max = 8, + get = function() + return Presets.items[idx].channel + end, set = function(v) Presets.items[idx].channel = v Presets.save() - end, - visible = function() return Presets.items[idx].band > 0 end, + end, + visible = function() + return Presets.items[idx].band > 0 + end, }) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 08a1b41..442fea4 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -17,28 +17,28 @@ local WidgetUI = {} -- 1/6: 69→~58 1/4: 104→~87 1/3: 139→116 1/2: 209→175 3/4: 313→~262 -- Thresholds must work for both decorated and undecorated layouts. WidgetUI.breakpoints = { - topBarW = 200, - sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) - quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) - thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) - halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) + topBarW = 200, + sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) + thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) + halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) } WidgetUI.fonts = { - sixth = { status = BOLD }, + sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = MIDSIZE }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = 0 }, + third = { status = MIDSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = 0 }, } - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, VTX = VTX, + Protocol = Protocol, + VTX = VTX, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. @@ -271,12 +271,18 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 0839c5c..9ab7d9f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -16,19 +16,19 @@ local WidgetUI = {} -- Portrait widget zones tend to be wider-relative-to-height than landscape. -- Height tiers are scaled for the taller 480px screen. WidgetUI.breakpoints = { - topBarW = 80, - sixthH = 70, + topBarW = 80, + sixthH = 70, quarterH = 100, - thirdH = 140, - halfH = 210, + thirdH = 140, + halfH = 210, } WidgetUI.fonts = { - sixth = { status = BOLD }, + sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } local function pitModeColor() @@ -51,11 +51,15 @@ end --- Shorter detail line for narrow portrait screen. local function detailLine() - if not Protocol.isActive() then return "" end - if VTX.state.band == 0 then return "" end - local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "" + end + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" local pit = VTX.state.pitmode and " Pit" or "" - return table.concat({pwr, pit}) + return table.concat({ pwr, pit }) end --- Build two narrow cheatsheet rows (3 labels each), or nil pair. @@ -74,25 +78,23 @@ local function buildCheatsheetNarrow() local hasModule = function() return Protocol.state ~= Protocol.STATE_NO_MODULE end - return - { - type = lvgl.BOX, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = hasModule, - children = row1, - }, - { - type = lvgl.BOX, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = hasModule, - children = row2, - } + return { + type = lvgl.BOX, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = hasModule, + children = row1, + }, { + type = lvgl.BOX, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = hasModule, + children = row2, + } end -- ============================================================================ @@ -100,7 +102,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, VTX = VTX, + Protocol = Protocol, + VTX = VTX, }) --- 1/6: single row with band/channel + compact detail. @@ -312,9 +315,9 @@ function WidgetUI.buildHalf(w, h, opa) if VTX.state.band == 0 then return "VTX Disabled" end - local pwr = VTX.state.power > 0 and table.concat({"Power ", VTX.state.power}) or "Power -" + local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" local pit = VTX.state.pitmode and " Pit" or "" - return table.concat({pwr, pit}) + return table.concat({ pwr, pit }) end, }, } @@ -389,12 +392,18 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 8b68f91..c4714e0 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -14,28 +14,28 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x272. WidgetUI.breakpoints = { - topBarW = 100, - sixthH = 50, + topBarW = 100, + sixthH = 50, quarterH = 70, - thirdH = 100, - halfH = 125, + thirdH = 100, + halfH = 125, } WidgetUI.fonts = { - sixth = { status = BOLD }, + sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, VTX = VTX, + Protocol = Protocol, + VTX = VTX, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. @@ -268,12 +268,18 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index f4fc0e6..f3e5084 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -15,19 +15,19 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x320. -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { - topBarW = 100, - sixthH = 50, + topBarW = 100, + sixthH = 50, quarterH = 62, - thirdH = 118, - halfH = 147, + thirdH = 118, + halfH = 147, } WidgetUI.fonts = { - sixth = { status = BOLD }, + sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } local function pitModeColor() @@ -44,13 +44,13 @@ local function pitModeText() return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, VTX = VTX, + Protocol = Protocol, + VTX = VTX, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. @@ -295,12 +295,18 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index f26b756..6e28899 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -15,19 +15,19 @@ local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x240. -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { - topBarW = 80, - sixthH = 38, + topBarW = 80, + sixthH = 38, quarterH = 54, - thirdH = 76, - halfH = 100, + thirdH = 76, + halfH = 100, } WidgetUI.fonts = { - sixth = { status = BOLD }, + sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, } local function pitModeColor() @@ -59,7 +59,8 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, VTX = VTX, + Protocol = Protocol, + VTX = VTX, }) --- 1/6: single row with band + status + power + pit mode + cheatsheet. @@ -328,12 +329,18 @@ function WidgetUI.build(wgtZone, opts) local w, h = wgtZone.w, wgtZone.h local opa = bgOpacity(opts) local bp = WidgetUI.breakpoints - if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then WidgetUI.buildSixth(w, h, opa) - elseif h < bp.quarterH then WidgetUI.buildQuarter(w, h, opa) - elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) - elseif h < bp.halfH then WidgetUI.buildHalf(w, h, opa) - else WidgetUI.buildFull(w, h, opa) + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua index 5b951a5..99366ea 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -16,7 +16,7 @@ local function getStatusLine() if VTX.state.band == 0 then return "--" end - return table.concat({VTX.state.bandLetter, VTX.state.channel}) + return table.concat({ VTX.state.bandLetter, VTX.state.channel }) end --- Top bar: ultra-compact single line, no background. @@ -42,8 +42,8 @@ function TopBarUI.build(w, h) if s == "--" then return s end - local pwr = VTX.state.power > 0 and table.concat({"P", VTX.state.power}) or "P-" - return table.concat({s, pwr}, " ") + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + return table.concat({ s, pwr }, " ") end, }, }, diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 7b604a3..2c5b0d6 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -41,43 +41,43 @@ local config = { local CRSF = { -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, -- Addresses - ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, -- Field types0 - UINT8 = 0, - INT8 = 1, - UINT16 = 2, - INT16 = 3, - FLOAT = 8, - TEXT_SELECTION = 9, - STRING = 10, - FOLDER = 11, - INFO = 12, - COMMAND = 13, + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, -- ELRS identification ELRS_SERIAL_ID = 0x454C5253, -- Command steps - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, CMD_ASKCONFIRM = 3, - CMD_CONFIRMED = 4, - CMD_CANCEL = 5, - CMD_QUERY = 6, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, } -- ============================================================================ @@ -88,10 +88,10 @@ local CRSF = { -- ============================================================================ local rateConfigs = { - [0] = { hz = 50, interval = 20000, defaultTlm = 5 }, -- TLM_RATIO_1_16 - [1] = { hz = 150, interval = 6666, defaultTlm = 4 }, -- TLM_RATIO_1_32 - [2] = { hz = 250, interval = 4000, defaultTlm = 3 }, -- TLM_RATIO_1_64 - [3] = { hz = 500, interval = 2000, defaultTlm = 2 }, -- TLM_RATIO_1_128 + [0] = { hz = 50, interval = 20000, defaultTlm = 5 }, -- TLM_RATIO_1_16 + [1] = { hz = 150, interval = 6666, defaultTlm = 4 }, -- TLM_RATIO_1_32 + [2] = { hz = 250, interval = 4000, defaultTlm = 3 }, -- TLM_RATIO_1_64 + [3] = { hz = 500, interval = 2000, defaultTlm = 2 }, -- TLM_RATIO_1_128 } -- ============================================================================ @@ -134,7 +134,7 @@ end -- Slow loading scenario: time-delayed response queue. -- PARAMETER_READ responses are held here until their delivery time, then -- promoted to the main queue so the Lua script sees realistic latency. -local SLOW_LOADING_DELAY_TICKS = 200 -- 2 seconds per field (getTime() at 10ms/tick) +local SLOW_LOADING_DELAY_TICKS = 200 -- 2 seconds per field (getTime() at 10ms/tick) local delayedResponseQueue = {} -- Deferred folder name updates simulate the firmware event loop gap: @@ -143,7 +143,7 @@ local delayedResponseQueue = {} -- A PARAMETER_READ arriving before that gets stale dynName. -- Delay is time-based (getTime() ticks, 10ms each) to be independent of -- how often mockPop is called within a single Protocol.poll() cycle. -local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20ms delay +local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20ms delay local folderNamesReadyAt = 0 local folderNamesDevice = nil @@ -191,7 +191,7 @@ local function appendString(tbl, str) for i = 1, #str do tbl[#tbl + 1] = string.byte(str, i) end - tbl[#tbl + 1] = 0 -- null terminator + tbl[#tbl + 1] = 0 -- null terminator end local function appendU32BE(tbl, val) @@ -225,7 +225,7 @@ local function encodeDeviceInfo(device, destAddr) -- Hardware version (4 bytes BE) appendU32BE(data, device.hwVer or 0) -- Software version (4 bytes BE) - appendU32BE(data, device.swVer or 0x00030500) -- 3.5.0 + appendU32BE(data, device.swVer or 0x00030500) -- 3.5.0 -- Field count data[#data + 1] = device.fieldCount -- Parameter version @@ -244,10 +244,10 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) local data = {} data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER data[2] = device.id - data[3] = param.id -- Field ID - data[4] = 0 -- Chunks remaining (0 = single chunk) - data[5] = param.parent or 0 -- Parent ID (0 = root) - data[6] = param.type -- Type byte (with hidden flag if needed) + data[3] = param.id -- Field ID + data[4] = 0 -- Chunks remaining (0 = single chunk) + data[5] = param.parent or 0 -- Parent ID (0 = root) + data[6] = param.type -- Type byte (with hidden flag if needed) if param.hidden then data[6] = bit32.bor(data[6], 0x80) end @@ -268,7 +268,7 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) -- Max (count of options - 1) local optCount = 1 for i = 1, #param.options do - if string.byte(param.options, i) == 59 then -- ';' + if string.byte(param.options, i) == 59 then -- ';' optCount = optCount + 1 end end @@ -277,7 +277,6 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) data[#data + 1] = 0 -- Units (null-terminated) appendString(data, param.units or "") - elseif t == CRSF.COMMAND then -- Status data[#data + 1] = param.status or CRSF.CMD_IDLE @@ -285,25 +284,21 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) data[#data + 1] = param.timeout or 200 -- Info string (null-terminated) appendString(data, param.info or "") - elseif t == CRSF.FOLDER then -- Folder contains a list of child parameter IDs terminated by 0xFF. -- This allows the Lua script to know which fields to load for this folder. -- We need the device context to scan for children. if param._device then for _, p in ipairs(param._device.params) do - if (param.id == 0 and (p.parent == 0 or p.parent == nil)) or - (param.id ~= 0 and p.parent == param.id) then + if (param.id == 0 and (p.parent == 0 or p.parent == nil)) or (param.id ~= 0 and p.parent == param.id) then data[#data + 1] = p.id end end end - data[#data + 1] = 0xFF -- terminator - + data[#data + 1] = 0xFF -- terminator elseif t == CRSF.INFO or t == CRSF.STRING then -- INFO (read-only) and STRING (editable) both encode as null-terminated string appendString(data, param.value or "") - elseif t == CRSF.UINT8 then -- value, min, max (1 byte each) data[#data + 1] = param.value or 0 @@ -313,7 +308,6 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) data[#data + 1] = param.default or 0 -- units appendString(data, param.units or "") - elseif t == CRSF.INT8 then -- Same as UINT8 but values may be signed (stored as unsigned in wire format) local v = param.value or 0 @@ -333,7 +327,6 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) data[#data + 1] = mx data[#data + 1] = param.default or 0 appendString(data, param.units or "") - elseif t == CRSF.UINT16 or t == CRSF.INT16 then -- value, min, max (2 bytes BE each) appendU16BE(data, param.value or 0) @@ -342,7 +335,6 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) -- default (2 bytes) appendU16BE(data, param.default or 0) appendString(data, param.units or "") - elseif t == CRSF.FLOAT then -- value, min, max, default (4 bytes BE each), precision (1 byte), step (4 bytes BE) appendU32BE(data, param.value or 0) @@ -388,60 +380,164 @@ local txDevice = { name = "TX16S MK3", serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, - swVer = 0x00030500, -- 3.5.0 - fieldCount = 21, -- total parameter count + swVer = 0x00030500, -- 3.5.0 + fieldCount = 21, -- total parameter count params = { - { id = 1, parent = 0, type = CRSF.TEXT_SELECTION, name = "Packet Rate", - options = "50(-117dBm);150(-112dBm);250(-108dBm);500(-105dBm)", value = 2, units = "Hz" }, - { id = 2, parent = 0, type = CRSF.TEXT_SELECTION, name = "Telem Ratio", - options = "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race", value = 0, units = " (1:64)" }, - { id = 3, parent = 0, type = CRSF.TEXT_SELECTION, name = "Switch Mode", - options = "Hybrid;Wide", value = 1, units = "" }, - { id = 4, parent = 0, type = CRSF.TEXT_SELECTION, name = "Model Match", - options = "Off;On", value = 0, units = "(ID: 1)" }, - { id = 5, parent = 0, type = CRSF.TEXT_SELECTION, name = "Antenna Mode", - options = "Gemini;Ant 1;Ant 2;Switch", value = 0, units = "" }, + { + id = 1, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Packet Rate", + options = "50(-117dBm);150(-112dBm);250(-108dBm);500(-105dBm)", + value = 2, + units = "Hz", + }, + { + id = 2, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Telem Ratio", + options = "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race", + value = 0, + units = " (1:64)", + }, + { + id = 3, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Switch Mode", + options = "Hybrid;Wide", + value = 1, + units = "", + }, + { + id = 4, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Model Match", + options = "Off;On", + value = 0, + units = "(ID: 1)", + }, + { + id = 5, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Antenna Mode", + options = "Gemini;Ant 1;Ant 2;Switch", + value = 0, + units = "", + }, -- TX Power folder - { id = 6, parent = 0, type = CRSF.FOLDER, name = "TX Power" }, - { id = 7, parent = 6, type = CRSF.TEXT_SELECTION, name = "Max Power", - options = "10/10;25/25;25/50;25/100;25/250;25/500;25/1000;25/2000", value = 3, units = "mW" }, - { id = 8, parent = 6, type = CRSF.TEXT_SELECTION, name = "Dynamic", - options = "Off;Dyn;AUX9;AUX10;AUX11;AUX12", value = 1, units = "" }, - { id = 9, parent = 6, type = CRSF.TEXT_SELECTION, name = "Fan Thresh", - options = "10mW;25mW;50mW;100mW;250mW;500mW;1000mW;2000mW;Never", value = 3, units = "" }, + { id = 6, parent = 0, type = CRSF.FOLDER, name = "TX Power" }, + { + id = 7, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Max Power", + options = "10/10;25/25;25/50;25/100;25/250;25/500;25/1000;25/2000", + value = 3, + units = "mW", + }, + { + id = 8, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Dynamic", + options = "Off;Dyn;AUX9;AUX10;AUX11;AUX12", + value = 1, + units = "", + }, + { + id = 9, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Fan Thresh", + options = "10mW;25mW;50mW;100mW;250mW;500mW;1000mW;2000mW;Never", + value = 3, + units = "", + }, -- VTX Administrator folder { id = 10, parent = 0, type = CRSF.FOLDER, name = "VTX Administrator" }, - { id = 11, parent = 10, type = CRSF.TEXT_SELECTION, name = "Band", - options = "Off;A;B;E;F;R;L", value = 5, units = "" }, - { id = 12, parent = 10, type = CRSF.UINT8, name = "Channel", - value = 1, min = 1, max = 8, units = "" }, - { id = 13, parent = 10, type = CRSF.TEXT_SELECTION, name = "Pwr Lvl", - options = "-;1;2;3;4;5;6;7;8", value = 0, units = "" }, - { id = 14, parent = 10, type = CRSF.TEXT_SELECTION, name = "Pitmode", - options = "Off;On", value = 0, units = "" }, - { id = 15, parent = 10, type = CRSF.COMMAND, name = "Send VTx", - status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + { + id = 11, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Band", + options = "Off;A;B;E;F;R;L", + value = 5, + units = "", + }, + { id = 12, parent = 10, type = CRSF.UINT8, name = "Channel", value = 1, min = 1, max = 8, units = "" }, + { + id = 13, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Pwr Lvl", + options = "-;1;2;3;4;5;6;7;8", + value = 0, + units = "", + }, + { + id = 14, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Pitmode", + options = "Off;On", + value = 0, + units = "", + }, + { + id = 15, + parent = 10, + type = CRSF.COMMAND, + name = "Send VTx", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, -- WiFi Connectivity folder { id = 16, parent = 0, type = CRSF.FOLDER, name = "WiFi Connectivity" }, - { id = 17, parent = 16, type = CRSF.COMMAND, name = "Enable WiFi", - status = CRSF.CMD_IDLE, timeout = 50, info = "", persistent = true }, -- runs until cancelled - { id = 18, parent = 16, type = CRSF.COMMAND, name = "Enable Rx WiFi", - status = CRSF.CMD_IDLE, timeout = 50, info = "", persistent = true }, -- runs until cancelled + { + id = 17, + parent = 16, + type = CRSF.COMMAND, + name = "Enable WiFi", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + persistent = true, + }, -- runs until cancelled + { + id = 18, + parent = 16, + type = CRSF.COMMAND, + name = "Enable Rx WiFi", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + persistent = true, + }, -- runs until cancelled -- Root-level commands and info - { id = 19, parent = 0, type = CRSF.COMMAND, name = "Bind", - status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + { + id = 19, + parent = 0, + type = CRSF.COMMAND, + name = "Bind", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, -- Bad/Good (hidden from ELRS Lua, visible to other UIs) - { id = 20, parent = 0, type = CRSF.INFO, name = "Bad/Good", - value = "0/250", hidden = true }, + { id = 20, parent = 0, type = CRSF.INFO, name = "Bad/Good", value = "0/250", hidden = true }, -- Version + regulatory domain (name = version+domain, value = commit hash) - { id = 21, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", - value = "825ed8" }, + { id = 21, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, }, } @@ -455,66 +551,159 @@ local rxDevice = { name = "ELRS 2400RX", serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, - swVer = 0x00030500, -- 3.5.0 - fieldCount = 22, -- total parameter count + swVer = 0x00030500, -- 3.5.0 + fieldCount = 22, -- total parameter count params = { - { id = 1, parent = 0, type = CRSF.TEXT_SELECTION, name = "Protocol", + { + id = 1, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Protocol", options = "CRSF;Inverted CRSF;SBUS;Inverted SBUS;SUMD;DJI RS Pro;HoTT Telemetry;MAVLink;DisplayPort;GPS", - value = 0, units = "" }, - { id = 2, parent = 0, type = CRSF.TEXT_SELECTION, name = "SBUS failsafe", - options = "No Pulses;Last Pos", value = 0, units = "" }, - { id = 3, parent = 0, type = CRSF.TEXT_SELECTION, name = "Ant. Mode", - options = "Antenna A;Antenna B;Diversity", value = 2, units = "" }, - { id = 4, parent = 0, type = CRSF.TEXT_SELECTION, name = "Tlm Power", - options = "10;25;50;100;250;MatchTX", value = 2, units = "mW" }, + value = 0, + units = "", + }, + { + id = 2, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "SBUS failsafe", + options = "No Pulses;Last Pos", + value = 0, + units = "", + }, + { + id = 3, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Ant. Mode", + options = "Antenna A;Antenna B;Diversity", + value = 2, + units = "", + }, + { + id = 4, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Tlm Power", + options = "10;25;50;100;250;MatchTX", + value = 2, + units = "mW", + }, -- Team Race folder - { id = 5, parent = 0, type = CRSF.FOLDER, name = "Team Race" }, - { id = 6, parent = 5, type = CRSF.TEXT_SELECTION, name = "Channel", + { id = 5, parent = 0, type = CRSF.FOLDER, name = "Team Race" }, + { + id = 6, + parent = 5, + type = CRSF.TEXT_SELECTION, + name = "Channel", options = "AUX2;AUX3;AUX4;AUX5;AUX6;AUX7;AUX8;AUX9;AUX10;AUX11;AUX12", - value = 0, units = "" }, - { id = 7, parent = 5, type = CRSF.TEXT_SELECTION, name = "Position", - options = "Disabled;1/Low;2;3;Mid;4;5;6/High", value = 0, units = "" }, + value = 0, + units = "", + }, + { + id = 7, + parent = 5, + type = CRSF.TEXT_SELECTION, + name = "Position", + options = "Disabled;1/Low;2;3;Mid;4;5;6/High", + value = 0, + units = "", + }, -- Output Mapping folder - { id = 8, parent = 0, type = CRSF.FOLDER, name = "Output Mapping" }, - { id = 9, parent = 8, type = CRSF.UINT8, name = "Output Ch", - value = 1, min = 1, max = 4, units = "" }, - { id = 10, parent = 8, type = CRSF.UINT8, name = "Input Ch", - value = 1, min = 1, max = 16, units = "" }, - { id = 11, parent = 8, type = CRSF.TEXT_SELECTION, name = "Output Mode", + { id = 8, parent = 0, type = CRSF.FOLDER, name = "Output Mapping" }, + { id = 9, parent = 8, type = CRSF.UINT8, name = "Output Ch", value = 1, min = 1, max = 4, units = "" }, + { id = 10, parent = 8, type = CRSF.UINT8, name = "Input Ch", value = 1, min = 1, max = 16, units = "" }, + { + id = 11, + parent = 8, + type = CRSF.TEXT_SELECTION, + name = "Output Mode", options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz;10kHzDuty;On/Off;DShot", - value = 0, units = "" }, - { id = 12, parent = 8, type = CRSF.TEXT_SELECTION, name = "Invert", - options = "Off;On", value = 0, units = "" }, + value = 0, + units = "", + }, + { + id = 12, + parent = 8, + type = CRSF.TEXT_SELECTION, + name = "Invert", + options = "Off;On", + value = 0, + units = "", + }, -- PWM Channel 1 subfolder (nested inside Output Mapping) { id = 13, parent = 8, type = CRSF.FOLDER, name = "PWM Ch1" }, - { id = 14, parent = 13, type = CRSF.UINT8, name = "Failsafe", - value = 0, min = 0, max = 100, units = "%" }, - { id = 15, parent = 13, type = CRSF.TEXT_SELECTION, name = "Mode", - options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", value = 0, units = "" }, + { + id = 14, + parent = 13, + type = CRSF.UINT8, + name = "Failsafe", + value = 0, + min = 0, + max = 100, + units = "%", + }, + { + id = 15, + parent = 13, + type = CRSF.TEXT_SELECTION, + name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", + value = 0, + units = "", + }, -- PWM Channel 2 subfolder (nested inside Output Mapping) { id = 16, parent = 8, type = CRSF.FOLDER, name = "PWM Ch2" }, - { id = 17, parent = 16, type = CRSF.UINT8, name = "Failsafe", - value = 0, min = 0, max = 100, units = "%" }, - { id = 18, parent = 16, type = CRSF.TEXT_SELECTION, name = "Mode", - options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", value = 0, units = "" }, + { + id = 17, + parent = 16, + type = CRSF.UINT8, + name = "Failsafe", + value = 0, + min = 0, + max = 100, + units = "%", + }, + { + id = 18, + parent = 16, + type = CRSF.TEXT_SELECTION, + name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", + value = 0, + units = "", + }, -- Bind Storage & Bind Mode - { id = 19, parent = 0, type = CRSF.TEXT_SELECTION, name = "Bind Storage", - options = "Persistent;Volatile;Returnable;Administered", value = 0, units = "" }, - { id = 20, parent = 0, type = CRSF.COMMAND, name = "Enter Bind Mode", - status = CRSF.CMD_IDLE, timeout = 50, info = "" }, + { + id = 19, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Bind Storage", + options = "Persistent;Volatile;Returnable;Administered", + value = 0, + units = "", + }, + { + id = 20, + parent = 0, + type = CRSF.COMMAND, + name = "Enter Bind Mode", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, -- Model Id - { id = 21, parent = 0, type = CRSF.INFO, name = "Model Id", - value = "12" }, + { id = 21, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" }, -- Info fields - { id = 22, parent = 0, type = CRSF.INFO, name = "RX Version", - value = "3.5.0 825ed8" }, + { id = 22, parent = 0, type = CRSF.INFO, name = "RX Version", value = "3.5.0 825ed8" }, }, } @@ -568,8 +757,8 @@ end local function updateFolderNames(device) -- TX Power folder (id=6): children Max Power (id=7), Dynamic (id=8) local txPwrFolder = findParam(device, 6) - local maxPower = findParam(device, 7) - local dynamic = findParam(device, 8) + local maxPower = findParam(device, 7) + local dynamic = findParam(device, 8) if txPwrFolder and maxPower then local pwrLabel = getOptionLabel(maxPower.options, maxPower.value or 0) local name = "TX Power (" .. pwrLabel @@ -584,10 +773,10 @@ local function updateFolderNames(device) -- VTX Administrator folder (id=10): children Band (id=11), Channel (id=12), -- Pwr Lvl (id=13), Pitmode (id=14) local vtxFolder = findParam(device, 10) - local vtxBand = findParam(device, 11) - local vtxChan = findParam(device, 12) - local vtxPwr = findParam(device, 13) - local vtxPit = findParam(device, 14) + local vtxBand = findParam(device, 11) + local vtxChan = findParam(device, 12) + local vtxPwr = findParam(device, 13) + local vtxPit = findParam(device, 14) if vtxFolder and vtxBand then local bandVal = vtxBand.value or 0 if bandVal == 0 then @@ -632,10 +821,10 @@ end local function tlmRatioEnumToValue(enumval) if enumval <= 1 then return 1 - end -- Std/Off -> 1 (caller handles display) + end -- Std/Off -> 1 (caller handles display) if enumval >= 9 then return 1 - end -- Race -> same as Std + end -- Race -> same as Std -- 2=1:128 -> 128, 3=1:64 -> 64, … 8=1:2 -> 2 -- Formula: 2^(8 + 1 - enumval) (matching firmware: 1 << (8 + TLM_RATIO_NO_TLM - enumval)) return math.floor(2 ^ (9 - enumval)) @@ -660,9 +849,9 @@ end -- Mirrors firmware updateTlmBandwidth() from TXModuleParameters.cpp. -- @param device the device table (txDevice) local function updateTlmBandwidth(device) - local packetRate = findParam(device, 1) -- Packet Rate - local telemRatio = findParam(device, 2) -- Telem Ratio - local switchMode = findParam(device, 3) -- Switch Mode + local packetRate = findParam(device, 1) -- Packet Rate + local telemRatio = findParam(device, 2) -- Telem Ratio + local switchMode = findParam(device, 3) -- Switch Mode if not packetRate or not telemRatio then return end @@ -719,8 +908,8 @@ updateTlmBandwidth(txDevice) -- ============================================================================ -- Reconnect scenario timing -local reconnectDelay = 500 -- ~5 seconds (getTime() ticks at 10ms) -local startTime = nil -- set on first mockPush/mockPop call +local reconnectDelay = 500 -- ~5 seconds (getTime() ticks at 10ms) +local startTime = nil -- set on first mockPush/mockPop call -- Dynamic RX availability (replaces static hasRxDevice boolean) local function isRxAvailable() @@ -745,13 +934,13 @@ local function getElrsFlags() if config.scenario == "reconnect" then return isRxAvailable() and 0x01 or 0x00 elseif config.scenario == "model_mismatch" then - return 0x05 -- connected + model mismatch + return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then - return 0x09 -- connected + armed + return 0x09 -- connected + armed elseif config.scenario == "normal" or config.scenario == "slow_loading" then - return 0x01 -- connected + return 0x01 -- connected else - return 0x00 -- disconnected + return 0x00 -- disconnected end end @@ -768,7 +957,7 @@ end -- Command state machine (per-parameter) -- ============================================================================ -local commandStates = {} -- keyed by "deviceId:paramId" +local commandStates = {} -- keyed by "deviceId:paramId" local function getCommandKey(deviceId, paramId) return tostring(deviceId) .. ":" .. tostring(paramId) @@ -797,7 +986,7 @@ local function handleCommandWrite(device, param, newStatus) state.status = CRSF.CMD_EXECUTING state.info = "Executing..." if param.persistent then - state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi) + state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi) else state.queriesRemaining = COMMAND_EXECUTE_POLLS end @@ -844,7 +1033,6 @@ local function mockPush(command, data) queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, destAddr)) end return true - elseif command == CRSF.FRAMETYPE_PARAMETER_READ then -- Parameter read request: data = { deviceId, handsetId, fieldId, chunk } local deviceId = data[1] @@ -871,7 +1059,7 @@ local function mockPush(command, data) -- Set device context for folder child ID encoding param._device = param._device or device local entry = encodeParameterEntry(device, param, chunk, destAddr) - param._device = nil -- clean up temporary reference + param._device = nil -- clean up temporary reference if config.scenario == "slow_loading" then -- Delay response to simulate slow OTA field loading delayedResponseQueue[#delayedResponseQueue + 1] = { @@ -885,7 +1073,6 @@ local function mockPush(command, data) end end return true - elseif command == CRSF.FRAMETYPE_PARAMETER_WRITE then -- Parameter write: data = { deviceId, handsetId, fieldId, value/status } local deviceId = data[1] @@ -897,8 +1084,7 @@ local function mockPush(command, data) local flags = getElrsFlags() local flagsInfo = getElrsFlagsInfo() local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER - queuePush(CRSF.FRAMETYPE_ELRS_STATUS, - encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) + queuePush(CRSF.FRAMETYPE_ELRS_STATUS, encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) return true end @@ -912,8 +1098,7 @@ local function mockPush(command, data) handleCommandWrite(device, param, writeValue) -- Queue the updated parameter entry as response local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER - queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, - encodeParameterEntry(device, param, 0, destAddr)) + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, encodeParameterEntry(device, param, 0, destAddr)) else -- Value write: update the stored value immediately (matches -- firmware config.Set*() which stores in RAM right away). @@ -985,59 +1170,88 @@ local txModuleTelemetry = { TPWR = 50 } local scenarioTelemetry = { normal = { - TPWR = 50, RFMD = 7, - ["1RSS"] = -87, ["2RSS"] = -93, - RQly = 99, ANT = 1, - RxBt = 15.2, Curr = 12.5, - FM = "ACRO", Sats = 12, GSpd = 25.3, Alt = 142, + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, }, armed = { - TPWR = 250, RFMD = 7, - ["1RSS"] = -78, ["2RSS"] = -82, - RQly = 100, ANT = 0, - RxBt = 14.8, Curr = 28.5, - FM = "ACRO", Sats = 14, GSpd = 42.7, Alt = 85, + TPWR = 250, + RFMD = 7, + ["1RSS"] = -78, + ["2RSS"] = -82, + RQly = 100, + ANT = 0, + RxBt = 14.8, + Curr = 28.5, + FM = "ACRO", + Sats = 14, + GSpd = 42.7, + Alt = 85, }, model_mismatch = { - TPWR = 50, RFMD = 7, - ["1RSS"] = -90, ["2RSS"] = -95, - RQly = 95, ANT = 1, - RxBt = 15.8, Curr = 0.5, + TPWR = 50, + RFMD = 7, + ["1RSS"] = -90, + ["2RSS"] = -95, + RQly = 95, + ANT = 1, + RxBt = 15.8, + Curr = 0.5, }, reconnect = { -- Same as normal; only served when isRxAvailable() is true - TPWR = 50, RFMD = 7, - ["1RSS"] = -87, ["2RSS"] = -93, - RQly = 99, ANT = 1, - RxBt = 15.2, Curr = 12.5, + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, }, slow_loading = { -- Same as normal; fields load slowly but telemetry is available - TPWR = 50, RFMD = 7, - ["1RSS"] = -87, ["2RSS"] = -93, - RQly = 99, ANT = 1, - RxBt = 15.2, Curr = 12.5, - FM = "ACRO", Sats = 12, GSpd = 25.3, Alt = 142, + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, }, } -- Jitter ranges for sensors that fluctuate in real life. -- Sensors not listed (TPWR, RFMD, ANT, FM, Sats) stay static. local sensorJitter = { - ["1RSS"] = 3, -- +/- 3 dBm + ["1RSS"] = 3, -- +/- 3 dBm ["2RSS"] = 3, - RQly = 2, -- +/- 2% - RxBt = 0.05, -- +/- 0.05V - Curr = 2.0, -- +/- 2A - GSpd = 3.0, - Alt = 5, + RQly = 2, -- +/- 2% + RxBt = 0.05, -- +/- 0.05V + Curr = 2.0, -- +/- 2A + GSpd = 3.0, + Alt = 5, } -- Telemetry values are cached and only refreshed once per second to match -- realistic sensor update rates and avoid excessive CPU in the simulator. local telemetryCache = {} local lastTelemetryUpdate = 0 -local TELEMETRY_UPDATE_TICKS = 100 -- 100 ticks = 1 second (getTime() at 10ms/tick) +local TELEMETRY_UPDATE_TICKS = 100 -- 100 ticks = 1 second (getTime() at 10ms/tick) local function updateTelemetryCache() local now = getTime() From 21f8639f909d8707e2bdbea9e1bc16f8211a66bf Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 24 Feb 2026 22:28:17 +0200 Subject: [PATCH 052/218] fix styling when battery voltage is present --- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index a1eaaae..be11939 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -248,7 +248,7 @@ function WidgetUI.buildFull(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = COLOR_THEME_PRIMARY3, + color = COLOR_THEME_SECONDARY1, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 4655938..395d320 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -218,7 +218,7 @@ function WidgetUI.buildFull(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = COLOR_THEME_PRIMARY3, + color = COLOR_THEME_SECONDARY1, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index d496bb1..1ce0730 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -235,7 +235,7 @@ function WidgetUI.buildFull(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = COLOR_THEME_PRIMARY3, + color = COLOR_THEME_SECONDARY1, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 5dbe406..a6f3cfc 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -247,7 +247,7 @@ function WidgetUI.buildFull(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = COLOR_THEME_PRIMARY3, + color = COLOR_THEME_SECONDARY1, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index b15f2bc..b01453f 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -234,7 +234,7 @@ function WidgetUI.buildFull(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = COLOR_THEME_PRIMARY3, + color = COLOR_THEME_SECONDARY1, text = function() local vbat = crsf.getSensorValue("RxBt") if vbat == nil or vbat <= 0 then From 906df0aaec513293981d89b612f6b09cb52b76a8 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 24 Feb 2026 22:28:29 +0200 Subject: [PATCH 053/218] add shim --- src/SCRIPTS/ELRS/shim.lua | 83 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/SCRIPTS/ELRS/shim.lua diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua new file mode 100644 index 0000000..30dc16c --- /dev/null +++ b/src/SCRIPTS/ELRS/shim.lua @@ -0,0 +1,83 @@ +--------------------------------------------------------------------------- +-- B&W Compatibility Layer -- +-- -- +-- Polyfills for standard Lua library functions missing on B&W radios -- +-- (table.concat, table.remove) and shared helpers (sensor value cache). -- +-- -- +-- Lives in /SCRIPTS/ELRS/ alongside crsf.lua so it is available to -- +-- both color widgets and B&W telemetry scripts. -- +-- -- +-- Usage: local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -- +--------------------------------------------------------------------------- + +local shim = {} + +-- ============================================================================ +-- table.concat polyfill +-- On color LCD radios the table library is available; on B&W it is not. +-- ============================================================================ + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +-- ============================================================================ +-- table.remove polyfill +-- Removes and returns the element at pos (default: last element). +-- Shifts subsequent elements down to close the gap. +-- ============================================================================ + +if table and table.remove then + shim.tableRemove = table.remove +else + shim.tableRemove = function(t, pos) + local n = #t + if n == 0 then + return nil + end + pos = pos or n + local val = t[pos] + for i = pos, n - 1 do + t[i] = t[i + 1] + end + t[n] = nil + return val + end +end + +-- ============================================================================ +-- Cached sensor value reader +-- Wraps getFieldInfo() + getValue() with a string->ID cache so the name +-- lookup is only done once per sensor. Avoids repeated string searches in +-- the source table on every call. +-- ============================================================================ + +local vCache = {} + +function shim.getSensorValue(name) + local cid = vCache[name] + if cid == nil then + local info = getFieldInfo(name) + cid = info and info.id or 0 + vCache[name] = cid + end + return cid ~= 0 and getValue(cid) or nil +end + +return shim From 8ae2b53896df1a05de54f51ac69fff829e5e737c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 24 Feb 2026 22:38:47 +0200 Subject: [PATCH 054/218] armed flag text parity with the firmware --- test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 2c5b0d6..f1609da 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -948,7 +948,7 @@ local function getElrsFlagsInfo() if config.scenario == "model_mismatch" then return "Model Mismatch" elseif config.scenario == "armed" then - return "is Armed!" + return "[ ! Armed ! ]" end return "" end From 8b0eb7d5775490af5849a9748bc190c612182ebd Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 24 Feb 2026 22:38:58 +0200 Subject: [PATCH 055/218] handle warnings better --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 8 ++++++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index fb5d6a3..9a78549 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -162,6 +162,14 @@ function Protocol.isConnected() return bit32.btest(Protocol.elrsFlags, 1) end +function Protocol.isModelMismatch() + return bit32.btest(Protocol.elrsFlags, 0x04) +end + +function Protocol.hasCriticalError() + return Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD +end + -- Response timeout for PARAMETER_READ: -- 0.5s for local TX module, 5s for remote devices relayed over air link. function Protocol.fieldResponseTimeout() diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index c12ef31..4108eb6 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -584,7 +584,7 @@ local function handleWarning() end if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then if not UI.warningDialog and not UI.warningDismissed then - if Protocol.elrsFlagsInfo == "Model Mismatch" then + if Protocol.isModelMismatch() then UI.warningDialog = ModelMismatchDialog.show(function() UI.warningDismissed = true UI.warningDismissedAt = getTime() @@ -593,7 +593,7 @@ local function handleWarning() UI.warningDismissed = true App.shouldExit = true end) - else + elseif Protocol.hasCriticalError() then Dialogs.showMessage({ title = "Warning", message = Protocol.elrsFlagsInfo, From a028b1900d39ff03743a1ea3f120507a77a9d910 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 24 Feb 2026 22:57:34 +0200 Subject: [PATCH 056/218] fix telemetry widget for elrs v4 --- src/SCRIPTS/ELRS/crsf.lua | 3 --- src/WIDGETS/ELRSTelemetry/loadable.lua | 7 +++++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 6d39e42..8def65e 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -392,9 +392,6 @@ local function onDeviceInfo(data) 0, -101, } - else - info.RFMOD = { "", "25Hz", "50Hz", "100Hz", "150Hz", "200Hz", "250Hz", "500Hz" } - info.RFRSSI = { 0, -123, -115, -117, -112, -112, -108, -105 } end end diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 6d6d986..a0294e5 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -110,7 +110,7 @@ function Telemetry.getRangePct(tlm) if rssi == nil then return 0 end - local minrssi = (mod.RFRSSI and mod.RFRSSI[(tlm.rfmd or 0) + 1]) or -128 + local minrssi = (mod.RFRSSI and tlm.rfmd and mod.RFRSSI[tlm.rfmd + 1]) or -128 if rssi > -50 then rssi = -50 end @@ -127,8 +127,11 @@ end --- Get RF mode string from device info. function Telemetry.getRfModeStr(rfmd) + if not crsf.rxConnected or rfmd == nil then + return "" + end local mod = crsf.deviceInfo - return (mod.RFMOD and mod.RFMOD[(rfmd or 0) + 1]) or table.concat({ "RFMD", tostring(rfmd or 0) }) + return (mod.RFMOD and mod.RFMOD[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) end --- Update GPS cache from telemetry. From 1e74e665e3f846bae9c0544d259034555e4463fc Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 25 Feb 2026 08:19:36 +0200 Subject: [PATCH 057/218] Add separate alert for model mismatch --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 62 +++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index d585cbc..50afcfc 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -38,6 +38,10 @@ local UI = { titleShowWarn = nil, titleShowWarnTimeout = 100, + -- Warning dismissal (model mismatch) + warningDismissed = false, + warningDismissedAt = nil, + -- Command popup spinner commandRunningIndicator = 1, } @@ -94,13 +98,13 @@ end -- ============================================================================ function UI.handleNoModule() - UI.drawAlert(" No ExpressLRS", { - " Enable a CRSF Internal", - " or External module in", - " Model settings", - " If module is internal", - " also set Internal RF to", - " CRSF in SYS->Hardware", + UI.drawAlert(" No ExpressLRS", { + "Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + "also set Internal RF to", + "CRSF in SYS->Hardware", }) end @@ -128,6 +132,39 @@ function UI.render(event, _touchState) UI.forceRedraw = true end + -- Warning dismissal cooldown (60s before re-showing) + if UI.warningDismissedAt then + if Protocol.elrsFlags <= Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if time - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + elseif UI.warningDismissed and time - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + end + + -- Model mismatch alert (full-screen, blocks normal rendering) + if Protocol.isModelMismatch() and not UI.warningDismissed then + if event == EVT_VIRTUAL_ENTER then + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + UI.forceRedraw = true + return + elseif event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return + end + UI.drawAlert("Model Mismatch", { + "RX connected but", + "Model ID doesn't match.", + "To use this receiver:", + "Disable Model Match", + }, { left = "[OK]", right = "[RTN] Change model" }) + return + end + -- Force redraw during loading to show progress bar if #Protocol.loadQueue > 0 then UI.forceRedraw = true @@ -146,7 +183,7 @@ end -- Alert screen (clear screen + title + body messages) -- ============================================================================ -function UI.drawAlert(title, msgs) +function UI.drawAlert(title, msgs, actions) lcd.clear() local y = 0 lcd.drawText(2, y, title, MIDSIZE) @@ -155,6 +192,15 @@ function UI.drawAlert(title, msgs) lcd.drawText(2, y, msg) y = y + UI.textSize end + if actions then + y = y + UI.textSize + if actions.left then + lcd.drawText(2, y, actions.left, 0) + end + if actions.right then + lcd.drawText(LCD_W - 2, y, actions.right, RIGHT) + end + end end -- ============================================================================ From 10d5bfb5b328ff8a7dd02be6669722112f7b24c4 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 25 Feb 2026 10:34:39 +0200 Subject: [PATCH 058/218] improve copy on model mismatch --- src/SCRIPTS/ELRS/crsf.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 4 ++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 6 +++--- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 8 ++++++++ 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 8def65e..586dff2 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -17,7 +17,7 @@ local CRSF = {} -- ============================================================================ --- Named protocol constants (no magic numbers anywhere) +-- Named protocol constants -- ============================================================================ CRSF.CONST = { diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 50afcfc..73bdaf9 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -159,8 +159,8 @@ function UI.render(event, _touchState) UI.drawAlert("Model Mismatch", { "RX connected but", "Model ID doesn't match.", - "To use this receiver:", - "Disable Model Match", + "Toggle Model Match", + "to re-sync", }, { left = "[OK]", right = "[RTN] Change model" }) return end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 4108eb6..1b961e5 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -71,9 +71,9 @@ function ModelMismatchDialog.show(onContinue, onExit) flexPad = lvgl.PAD_SMALL, children = { { type = lvgl.LABEL, text = "Receiver connected but Model ID doesn't match." }, - { type = lvgl.LABEL, text = "This prevents controlling the wrong model." }, - { type = lvgl.LABEL, text = "To use this receiver:" }, - { type = lvgl.LABEL, text = "Set Model Match to OFF" }, + { type = lvgl.LABEL, text = "RC commands are blocked until resolved." }, + { type = lvgl.LABEL, text = "Toggle the Model Match setting to" }, + { type = lvgl.LABEL, text = "re-sync, or change the EdgeTX model." }, }, }, { diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 6e2e569..5f6c13f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1176,6 +1176,8 @@ end -- Widget lifecycle -- ============================================================================ +local lastBuilt6pos = -1 + local wgt = { zone = zone, options = options, @@ -1190,6 +1192,10 @@ end function wgt.refresh(_event, _touchState) wgt.background() + if lvgl.isFullScreen() and Presets.lastPos ~= lastBuilt6pos then + lastBuilt6pos = Presets.lastPos + buildFullScreen() + end end function wgt.update(newOptions) @@ -1199,7 +1205,9 @@ function wgt.update(newOptions) VTX.syncDesiredFromState() end buildFullScreen() + lastBuilt6pos = Presets.lastPos else + lastBuilt6pos = -1 WidgetUI.build(wgt.zone, wgt.options) end end From 770c5f92879e00fbc7d9fecd536dc86565f39777 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 25 Feb 2026 11:14:25 +0200 Subject: [PATCH 059/218] Add strings & float support lvgl --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 21 +++- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 113 ++++++++++++++----- test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 67 +++++++++-- 4 files changed, 165 insertions(+), 38 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 9a78549..cdb7bf7 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -420,7 +420,7 @@ function Protocol.fieldFloatLoad(field, data, offset) field.prec = 3 end field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f", field.unit or "" }) + field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" }) field.prec = 10 ^ field.prec end @@ -483,6 +483,23 @@ function Protocol.fieldIntSave(field) Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) end +function Protocol.fieldStringSave(field) + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + local val = field.value or "" + local maxlen = field.maxlen or 32 + if #val > maxlen then + val = string.sub(val, 1, maxlen) + end + for i = 1, #val do + local b = string.byte(val, i) + if b ~= 0 then + frame[#frame + 1] = b + end + end + frame[#frame + 1] = 0 + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + -- ============================================================================ -- Related fields reload (for value changes) -- ============================================================================ @@ -575,7 +592,7 @@ Protocol.handlers = { [Protocol.CRSF.INT64 + 1] = nil, [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = Protocol.fieldStringSave }, [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 73bdaf9..8e526b0 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -378,7 +378,7 @@ local function fieldIntDisplay(field, y, attr) end local function fieldFloatDisplay(field, y, attr) - lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec), attr) + lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec) .. (field.unit or ""), attr) end local function fieldTextSelDisplay(field, y, attr) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 1b961e5..a6bd422 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -730,6 +730,7 @@ end local IS_NARROW = LCD_W < 400 local LABEL_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 42 or 50) +local VALUE_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 58 or 50) function UI.createToggleRow(pg, field) pg:setting({ @@ -830,6 +831,71 @@ function UI.createChoiceRow(pg, field) end function UI.createNumberRow(pg, field) + local isFloat = field.type == Protocol.CRSF.FLOAT + local numberEdit = { + type = lvgl.NUMBER_EDIT, + min = field.min or 0, + max = field.max or 255, + get = function() + return field.value or 0 + end, + set = function(val) + field.value = val + end, + edited = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadParentFolder(field) + end, + display = function(val) + if isFloat then + return string.format(field.fmt or "%.0f", val / (field.prec or 1)) + end + return tostring(val) + end, + active = function() + return not field.disabled + end, + } + + local children + if field.unit then + children = { + { + type = lvgl.BOX, + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + children = { + numberEdit, + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = field.unit, + }, + }, + }, + }, + }, + } + else + numberEdit.x = LABEL_PCT + children = { numberEdit } + end + + pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + children = children, + }) +end + +function UI.createInfoRow(pg, field) pg:build({ { type = lvgl.SETTING, @@ -837,37 +903,16 @@ function UI.createNumberRow(pg, field) title = field.name, children = { { - type = lvgl.NUMBER_EDIT, + type = lvgl.LABEL, x = LABEL_PCT, - min = field.min or 0, - max = field.max or 255, - get = function() - return field.value or 0 - end, - set = function(val) - field.value = val - end, - edited = function(val) - field.value = val - Protocol.fieldIntSave(field) - Protocol.reloadParentFolder(field) - end, - display = function(val) - if field.type == Protocol.CRSF.FLOAT then - return string.format(field.fmt or "%.0f", val / (field.prec or 1)) - end - return table.concat({ tostring(val), field.unit or "" }) - end, - active = function() - return not field.disabled - end, + text = field.value, }, }, }, }) end -function UI.createInfoRow(pg, field) +function UI.createStringRow(pg, field) pg:build({ { type = lvgl.SETTING, @@ -875,9 +920,19 @@ function UI.createInfoRow(pg, field) title = field.name, children = { { - type = lvgl.LABEL, + type = lvgl.TEXT_EDIT, x = LABEL_PCT, - text = field.value, + w = VALUE_PCT, + value = field.value or "", + length = math.min(math.max(field.maxlen or 32, 32), 128), + set = function(val) + field.value = val + Protocol.fieldStringSave(field) + Protocol.reloadParentFolder(field) + end, + active = function() + return not field.disabled + end, }, }, }, @@ -932,7 +987,11 @@ function UI.buildFieldWidget(pg, field, folderWidth) end end - if fieldType == Protocol.CRSF.STRING or fieldType == Protocol.CRSF.INFO then + if fieldType == Protocol.CRSF.STRING then + return UI.createStringRow(pg, field) + end + + if fieldType == Protocol.CRSF.INFO then return UI.createInfoRow(pg, field) end end diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index f1609da..c054b9a 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -296,9 +296,11 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) end end data[#data + 1] = 0xFF -- terminator - elseif t == CRSF.INFO or t == CRSF.STRING then - -- INFO (read-only) and STRING (editable) both encode as null-terminated string + elseif t == CRSF.INFO then appendString(data, param.value or "") + elseif t == CRSF.STRING then + appendString(data, param.value or "") + data[#data + 1] = param.maxlen or 32 elseif t == CRSF.UINT8 then -- value, min, max (1 byte each) data[#data + 1] = param.value or 0 @@ -381,7 +383,7 @@ local txDevice = { serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, swVer = 0x00030500, -- 3.5.0 - fieldCount = 21, -- total parameter count + fieldCount = 23, -- total parameter count params = { { id = 1, @@ -533,11 +535,36 @@ local txDevice = { info = "", }, + -- Editable string field + { + id = 20, + parent = 0, + type = CRSF.STRING, + name = "Bind Phrase", + value = "default", + maxlen = 16, + }, + + -- Float field (scaled integer with precision) + { + id = 21, + parent = 0, + type = CRSF.FLOAT, + name = "Freq Offset", + value = 0, + min = -5000, + max = 5000, + default = 0, + prec = 2, + step = 1, + units = "kHz", + }, + -- Bad/Good (hidden from ELRS Lua, visible to other UIs) - { id = 20, parent = 0, type = CRSF.INFO, name = "Bad/Good", value = "0/250", hidden = true }, + { id = 22, parent = 0, type = CRSF.INFO, name = "Bad/Good", value = "0/250", hidden = true }, -- Version + regulatory domain (name = version+domain, value = commit hash) - { id = 21, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, + { id = 23, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, }, } @@ -1100,9 +1127,33 @@ local function mockPush(command, data) local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, encodeParameterEntry(device, param, 0, destAddr)) else - -- Value write: update the stored value immediately (matches - -- firmware config.Set*() which stores in RAM right away). - param.value = writeValue + -- Value write: decode based on field type + if t == CRSF.STRING then + local chars = {} + local i = 4 + while data[i] and data[i] ~= 0 do + chars[#chars + 1] = data[i] + i = i + 1 + end + param.value = (#chars > 0) and string.char(table.unpack(chars)) or "" + elseif t == CRSF.FLOAT then + local v = bit32.lshift(data[4] or 0, 24) + + bit32.lshift(data[5] or 0, 16) + + bit32.lshift(data[6] or 0, 8) + + (data[7] or 0) + if v >= 0x80000000 then + v = v - 0x100000000 + end + param.value = v + elseif t == CRSF.UINT16 or t == CRSF.INT16 then + local v = bit32.lshift(data[4] or 0, 8) + (data[5] or 0) + if t == CRSF.INT16 and v >= 0x8000 then + v = v - 0x10000 + end + param.value = v + else + param.value = writeValue + end -- Defer folder name and bandwidth updates to the next poll cycle. -- Real firmware runs updateFolderNames() in the event loop, not -- in the PARAMETER_WRITE handler. No auto-send of parent folder From 5cab7deca508958ae6f1b057e77d9448423a90c6 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 27 Feb 2026 21:58:35 +0200 Subject: [PATCH 060/218] after number edit reload all releated fields --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index a6bd422..997b746 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -845,7 +845,7 @@ function UI.createNumberRow(pg, field) edited = function(val) field.value = val Protocol.fieldIntSave(field) - Protocol.reloadParentFolder(field) + Protocol.reloadRelatedFields(field) end, display = function(val) if isFloat then From ba0b8fff98429886891b950a32608609e135354b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 12:42:17 +0200 Subject: [PATCH 061/218] remove 2.11 support --- README.md | 4 ++-- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 9 +++------ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ec8f32a..98e65a2 100644 --- a/README.md +++ b/README.md @@ -113,5 +113,5 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | Radio type | Firmware | ExpressLRS | |------------|----------|------------| -| Black & white LCD | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v3.0+ | -| Color LCD | EdgeTX 2.11.5+, 2.12-rc4+, or 3.0+ | v3.0+ | +| Black & white LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | +| Color LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 721a6d1..5fe5885 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -5,7 +5,7 @@ ---- # # ---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ---- # # ----- # Unified tool for BW and color LCD radios (EdgeTX 2.11+) # +---- # Unified tool for BW and color LCD radios (EdgeTX 2.12+) # ---- ######################################################################### local VERSION = "r2" diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 997b746..e590c63 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -1,6 +1,6 @@ ---- ######################################################################### ---- # LVGL UI: Color LCD rendering, dialogs, command pages # ----- # For color LCD radios with EdgeTX 2.11.4+ LVGL support # +---- # For color LCD radios with EdgeTX 2.12+ LVGL support # ---- ######################################################################### local deps = ... @@ -346,8 +346,6 @@ local function checkEdgeTxVersion() return tonumber(rc) >= 4 end return true - elseif maj == 2 and minor == 11 and rev >= 5 then - return true end return false @@ -373,7 +371,6 @@ local function showVersionRequired() flexPad = lvgl.PAD_SMALL, children = { { type = "label", text = "Requires EdgeTX:" }, - { type = "label", text = "- 2.11.5 or later" }, { type = "label", text = "- 2.12-rc4 or later" }, { type = "label", text = "- 3.0 or later" }, }, @@ -402,8 +399,8 @@ local function showLvglRequired() lcd.clear() lcd.drawText(5, 10, "LVGL support required", BOLD) lcd.drawText(5, 20, "Color LCD radio with", 0) - lcd.drawText(5, 30, "EdgeTX 2.11.5+, 2.12-rc4+,", 0) - lcd.drawText(5, 40, "or 3.0+ needed", 0) + lcd.drawText(5, 30, "EdgeTX 2.12-rc4+ or 3.0+", 0) + lcd.drawText(5, 40, "needed", 0) end -- ============================================================================ From 4328aba5ba37a7f93089e858bf1b296fd8ff9268 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 12:45:25 +0200 Subject: [PATCH 062/218] assign title to lvgl.choice --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 1 + src/WIDGETS/ELRSVTXAdmin/loadable.lua | 1 + 2 files changed, 2 insertions(+) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index e590c63..fc89fda 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -797,6 +797,7 @@ function UI.createChoiceRow(pg, field) children = { { type = lvgl.CHOICE, + title = field.name, values = filteredValues, get = function() return origToFiltered[field.value or 0] or 1 diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 5f6c13f..510ca63 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -924,6 +924,7 @@ end local function createChoiceRow(container, label, values, getFn, setFn) local ctrl = createRow(container, label) ctrl:choice({ + title = label, values = values, get = getFn, set = setFn, From 5569ebf15e16999fdf901059d3a02e3235651ba3 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 12:47:31 +0200 Subject: [PATCH 063/218] move device name to the bottom --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index fc89fda..0afb186 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -1038,10 +1038,6 @@ function UI.build() end end else - if currentFolder == nil then - UI.createInfoRow(fieldContainer, { name = "Device", value = Protocol.deviceName or "Searching..." }) - end - local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) local FOLDERS_PER_ROW = 2 if IS_NARROW then @@ -1094,6 +1090,10 @@ function UI.build() UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) end + if currentFolder == nil then + UI.createInfoRow(fieldContainer, { name = "Device name", value = Protocol.deviceName or "Searching..." }) + end + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then fieldContainer:button({ text = "Other Devices", From 9998b9d10f251713d031fefebb887843942337bc Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 13:07:31 +0200 Subject: [PATCH 064/218] rename "No link" to "No telemetry" --- README.md | 2 +- src/SCRIPTS/ELRS/crsf.lua | 10 +++---- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 18 ++++++------ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 2 +- src/WIDGETS/ELRSTelemetry/loadable.lua | 30 ++++++++++---------- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 4 +-- test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 8 +++--- 13 files changed, 43 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 98e65a2..42ba4f3 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | Scenario | Description | |----------|-------------| | `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | -| `disconnected` | TX present but no RX. Shows "No link" state. | +| `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | | `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | | `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | | `armed` | TX + RX connected with "is Armed" warning flag. | diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 586dff2..32b81ce 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -70,8 +70,8 @@ CRSF._handlers = {} -- Device info cache (populated by built-in DEVICE_INFO handler) CRSF.deviceInfo = {} --- RX connection state (populated by built-in ELRS_STATUS handler) -CRSF.rxConnected = false +-- Telemetry state (populated by built-in ELRS_STATUS handler) +CRSF.hasTelemetry = false CRSF.modelMismatch = false CRSF.elrsFlags = 0 CRSF.elrsFlagsInfo = "" @@ -245,7 +245,7 @@ function CRSF:requestDeviceInfo() end --- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). --- Updates rxConnected via the ELRS_STATUS handler on the next poll(). +-- Updates hasTelemetry via the ELRS_STATUS handler on the next poll(). -- Rate-limited to at most once per second. function CRSF:requestElrsStatus() local now = getTime() @@ -395,10 +395,10 @@ local function onDeviceInfo(data) end end --- ELRS_STATUS handler: updates rxConnected, modelMismatch, elrsFlagsInfo +-- ELRS_STATUS handler: updates hasTelemetry, modelMismatch, elrsFlagsInfo local function onElrsStatus(data) CRSF.elrsFlags = data[6] or 0 - CRSF.rxConnected = bit32.btest(CRSF.elrsFlags, 1) + CRSF.hasTelemetry = bit32.btest(CRSF.elrsFlags, 1) CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) -- Parse null-terminated warning info string starting at data[7] diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index cdb7bf7..7e10085 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -100,8 +100,8 @@ local Protocol = { expectChunksRemain = -1, backgroundLoading = false, - -- Connection transition tracking (for auto-discovery on reconnect) - wasConnected = false, + -- Telemetry transition tracking (for auto-discovery on reconnect) + hadTelemetry = false, } -- ============================================================================ @@ -135,7 +135,7 @@ function Protocol.reset() Protocol.loadQueue = {} Protocol.expectChunksRemain = -1 Protocol.backgroundLoading = false - Protocol.wasConnected = false + Protocol.hadTelemetry = false end -- ============================================================================ @@ -157,8 +157,8 @@ function Protocol.pingDevices() ) end --- Check connection state from elrsFlags -function Protocol.isConnected() +-- Check if telemetry is being received from the RX (elrsFlags bit 1) +function Protocol.hasTelemetry() return bit32.btest(Protocol.elrsFlags, 1) end @@ -742,12 +742,12 @@ function Protocol.poll() end function Protocol.tick() - -- Ping on connection transition (device may have changed) - local connected = Protocol.isConnected() - if connected and not Protocol.wasConnected then + -- Ping on telemetry transition (device may have changed) + local hasTelemetry = Protocol.hasTelemetry() + if hasTelemetry and not Protocol.hadTelemetry then Protocol.pingDevices() end - Protocol.wasConnected = connected + Protocol.hadTelemetry = hasTelemetry local time = getTime() -- Periodic ping for initial device discovery diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 8e526b0..f335da0 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -419,7 +419,7 @@ function UI.drawTitle() local barHeight = 9 local goodBadPkt = "" if Protocol.receivedPackets then - local state = Protocol.isConnected() and "C" or "-" + local state = Protocol.hasTelemetry() and "C" or "-" goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 0afb186..272df1c 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -664,7 +664,7 @@ function UI.getSubtitle() local subtitle = "" if Protocol.receivedPackets then - local state = Protocol.isConnected() and "Connected" or "No link" + local state = Protocol.hasTelemetry() and "Telemetry OK" or "No telemetry" subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) end diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index a0294e5..eb012b8 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -94,8 +94,8 @@ function Telemetry.statusText() if not crsf.hasCrsfModule() then return "No CRSF module" end - if not crsf.rxConnected then - return "No RX" + if not crsf.hasTelemetry then + return "No telemetry" end if crsf.modelMismatch then return "Model Mismatch" @@ -127,7 +127,7 @@ end --- Get RF mode string from device info. function Telemetry.getRfModeStr(rfmd) - if not crsf.rxConnected or rfmd == nil then + if not crsf.hasTelemetry or rfmd == nil then return "" end local mod = crsf.deviceInfo @@ -170,7 +170,7 @@ end --- Range percentage + RSSI text (e.g. "Range 69% -90dBm"). function Telemetry.signalText() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "" end local tlm = Telemetry.readLink() @@ -188,7 +188,7 @@ function Telemetry.rfDetailText() local tlm = Telemetry.readLink() local mode = Telemetry.getRfModeStr(tlm.rfmd) local parts = { mode } - if crsf.rxConnected and tlm.tpwr then + if crsf.hasTelemetry and tlm.tpwr then parts[#parts + 1] = table.concat({ tostring(tlm.tpwr), "mW" }) end return table.concat(parts, " ") @@ -351,8 +351,8 @@ local function buildFullScreen() if not Telemetry.hasModule() then return "No CRSF module" end - if not crsf.rxConnected then - return "No RX Connected" + if not crsf.hasTelemetry then + return "No telemetry" end if crsf.modelMismatch then return "Model Mismatch" @@ -413,7 +413,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Link Quality", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -421,7 +421,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "RSSI 1", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -432,7 +432,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "RSSI 2", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -448,7 +448,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Active Antenna", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -459,7 +459,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Range", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -471,7 +471,7 @@ local function buildFullScreen() createSectionHeader(fields, "Power") createDisplayRow(fields, "TX Power", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -482,7 +482,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Power Index", function() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -586,7 +586,7 @@ function wgt.refresh(_event, _touchState) wgt.background() -- Update diversity detection each tick - if crsf.rxConnected then + if crsf.hasTelemetry then local tlm = Telemetry.readLink() Telemetry.updateDiversity(tlm.ant) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index be11939..b1811c9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -38,7 +38,7 @@ local function heroColorMismatch() end local function detailColor() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end return Telemetry.rangeColor(Telemetry.smoothRng or 0) diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 395d320..9b57dc2 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -38,7 +38,7 @@ local function heroColorMismatch() end local function detailColor() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end return Telemetry.rangeColor(Telemetry.smoothRng or 0) diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 1ce0730..24dfb31 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -38,7 +38,7 @@ local function heroColorMismatch() end local function detailColor() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end return Telemetry.rangeColor(Telemetry.smoothRng or 0) diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index a6f3cfc..29feae0 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -39,7 +39,7 @@ local function heroColorMismatch() end local function detailColor() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end return Telemetry.rangeColor(Telemetry.smoothRng or 0) diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index b01453f..425b325 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -39,7 +39,7 @@ local function heroColorMismatch() end local function detailColor() - if not crsf.rxConnected then + if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end return Telemetry.rangeColor(Telemetry.smoothRng or 0) diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index b29c8e2..06c9ebc 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -43,7 +43,7 @@ function TopBarUI.build(w, h) if crsf.modelMismatch then return "Model" end - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() @@ -64,7 +64,7 @@ function TopBarUI.build(w, h) if crsf.modelMismatch then return "Mismatch" end - if not crsf.rxConnected then + if not crsf.hasTelemetry then return "--" end local tlm = Telemetry.readLink() diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index c054b9a..410895d 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -16,7 +16,7 @@ -- Scenarios: -- "normal" TX + RX connected. Happy path with full telemetry, link -- stats, and all parameters from both devices. --- "disconnected" TX present but no RX. Shows "No link" in subtitle. +-- "no_telemetry" TX present but no RX telemetry. Shows "No telemetry" in subtitle. -- No receiver device in Other Devices list. -- "reconnect" Starts disconnected, then transitions to connected after -- ~5 seconds. Tests auto-discovery of Other Devices on @@ -32,7 +32,7 @@ -- "no_module" No CRSF module found at all. Triggers the "No Module -- Found" error dialog immediately. local config = { - scenario = "normal", + scenario = "no_telemetry", } -- ============================================================================ @@ -946,7 +946,7 @@ local function isRxAvailable() end return getTime() - startTime >= reconnectDelay end - return config.scenario ~= "disconnected" + return config.scenario ~= "no_telemetry" end -- ELRS Lua flag bits (from TXModuleEndpoint.h): @@ -967,7 +967,7 @@ local function getElrsFlags() elseif config.scenario == "normal" or config.scenario == "slow_loading" then return 0x01 -- connected else - return 0x00 -- disconnected + return 0x00 -- no telemetry end end From b76395635a4bddb538d18ad889d0081bd2c68001 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 13:55:29 +0200 Subject: [PATCH 065/218] improve layout --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 26 +++++++++++++++++--- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 10 ++++++-- test/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 2 +- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 272df1c..00332a6 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -949,9 +949,15 @@ function UI.createFolderWidget(pg, field, width) end function UI.createCommandWidget(pg, field) - pg:button({ - text = field.name or "", + local wrapper = pg:box({ w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = { top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }, + }) + wrapper:button({ + text = field.name or "", + w = lvgl.PERCENT_SIZE + 99, press = function() Protocol.handleCommandSave(field) end, @@ -1026,9 +1032,15 @@ function UI.build() local currentFolder = Navigation.getCurrent() if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + local devicesBox = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_TINY, + }) for _, device in ipairs(Protocol.devices) do if device.id ~= Protocol.deviceId then - fieldContainer:button({ + devicesBox:button({ text = device.name or "Unknown", w = lvgl.PERCENT_SIZE + 100, press = function() @@ -1095,7 +1107,13 @@ function UI.build() end if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then - fieldContainer:button({ + local wrapper = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = lvgl.PAD_TINY, + }) + wrapper:button({ text = "Other Devices", w = lvgl.PERCENT_SIZE + 100, h = lvgl.UI_ELEMENT_HEIGHT * 2, diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 510ca63..92829bf 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1079,14 +1079,20 @@ local function buildFullScreen() Protocol.writeConfig() end) - fields:button({ + local sendWrapper = fields:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = { top = lvgl.PAD_SMALL, bottom = lvgl.PAD_SMALL }, + }) + sendWrapper:button({ text = function() if Protocol.isSending() then return "Sending..." end return "Send VTx" end, - w = lvgl.PERCENT_SIZE + 100, + w = lvgl.PERCENT_SIZE + 99, press = function() Protocol.writeConfig() Protocol.pushToVtx() diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 410895d..dc9b120 100644 --- a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -32,7 +32,7 @@ -- "no_module" No CRSF module found at all. Triggers the "No Module -- Found" error dialog immediately. local config = { - scenario = "no_telemetry", + scenario = "normal", } -- ============================================================================ From 6a7233d3058880cbb36c2c8c37ab19b9cdf8b3db Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 14:11:48 +0200 Subject: [PATCH 066/218] add github workflow --- .github/workflows/ci.yml | 26 +++++++++++++++++++ .luarc.json | 4 +-- Makefile | 23 +++++++++------- {types => edgetx-lua-stdlib}/audio.d.lua | 0 {types => edgetx-lua-stdlib}/bit32.d.lua | 0 {types => edgetx-lua-stdlib}/bitmap.d.lua | 0 {types => edgetx-lua-stdlib}/dir.d.lua | 0 {types => edgetx-lua-stdlib}/edgetx.d.lua | 0 {types => edgetx-lua-stdlib}/hardware.d.lua | 0 {types => edgetx-lua-stdlib}/lcd.d.lua | 0 .../lua-runtime.d.lua | 0 {types => edgetx-lua-stdlib}/lvgl.d.lua | 0 {types => edgetx-lua-stdlib}/model.d.lua | 0 {types => edgetx-lua-stdlib}/runtime.d.lua | 0 {types => edgetx-lua-stdlib}/serial.d.lua | 0 {types => edgetx-lua-stdlib}/telemetry.d.lua | 0 {types => edgetx-lua-stdlib}/time.d.lua | 0 {types => edgetx-lua-stdlib}/widget.d.lua | 0 18 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/ci.yml rename {types => edgetx-lua-stdlib}/audio.d.lua (100%) rename {types => edgetx-lua-stdlib}/bit32.d.lua (100%) rename {types => edgetx-lua-stdlib}/bitmap.d.lua (100%) rename {types => edgetx-lua-stdlib}/dir.d.lua (100%) rename {types => edgetx-lua-stdlib}/edgetx.d.lua (100%) rename {types => edgetx-lua-stdlib}/hardware.d.lua (100%) rename {types => edgetx-lua-stdlib}/lcd.d.lua (100%) rename {types => edgetx-lua-stdlib}/lua-runtime.d.lua (100%) rename {types => edgetx-lua-stdlib}/lvgl.d.lua (100%) rename {types => edgetx-lua-stdlib}/model.d.lua (100%) rename {types => edgetx-lua-stdlib}/runtime.d.lua (100%) rename {types => edgetx-lua-stdlib}/serial.d.lua (100%) rename {types => edgetx-lua-stdlib}/telemetry.d.lua (100%) rename {types => edgetx-lua-stdlib}/time.d.lua (100%) rename {types => edgetx-lua-stdlib}/widget.d.lua (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a5f10f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + name: Lint and Typecheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: ~/.cargo + key: cargo-stylua-${{ runner.os }} + + - name: Install tools + run: make install-tools + + - name: Run checks + run: make check diff --git a/.luarc.json b/.luarc.json index 9d8300f..50982c3 100644 --- a/.luarc.json +++ b/.luarc.json @@ -9,8 +9,8 @@ "io": "disable", "bit32": "disable" }, - "workspace.library": ["types"], - "workspace.ignoreDir": ["bin", "types"], + "workspace.library": ["edgetx-lua-stdlib"], + "workspace.ignoreDir": ["bin", "edgetx-lua-stdlib"], "workspace.checkThirdParty": false, "diagnostics.disable": ["lowercase-global"] } diff --git a/Makefile b/Makefile index 3aa24cf..b9e0d46 100644 --- a/Makefile +++ b/Makefile @@ -8,24 +8,29 @@ LUALS_DIR := bin/lua-language-server LUALS := $(LUALS_DIR)/bin/lua-language-server LUALS_URL := https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-linux-x64.tar.gz -.PHONY: help install-tools format format-check typecheck check +.PHONY: help install-tools install-stylua install-luals format format-check typecheck check help: @echo "Usage: make " @echo "" - @echo " install-tools Install stylua (via cargo) and lua-language-server" - @echo " format Format Lua files with stylua" - @echo " format-check Check formatting without modifying files" - @echo " typecheck Run lua-language-server type checking" - @echo " check Run format-check and typecheck" - -install-tools: - ec + @echo " install-tools Install stylua and lua-language-server" + @echo " install-stylua Install stylua (via cargo)" + @echo " install-luals Install lua-language-server" + @echo " format Format Lua files with stylua" + @echo " format-check Check formatting without modifying files" + @echo " typecheck Run lua-language-server type checking" + @echo " check Run format-check and typecheck" + +install-stylua: @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } cargo install stylua --features lua53 + +install-luals: mkdir -p $(LUALS_DIR) curl -fSL $(LUALS_URL) | tar xz -C $(LUALS_DIR) +install-tools: install-stylua install-luals + format: stylua $(LUA_DIRS) diff --git a/types/audio.d.lua b/edgetx-lua-stdlib/audio.d.lua similarity index 100% rename from types/audio.d.lua rename to edgetx-lua-stdlib/audio.d.lua diff --git a/types/bit32.d.lua b/edgetx-lua-stdlib/bit32.d.lua similarity index 100% rename from types/bit32.d.lua rename to edgetx-lua-stdlib/bit32.d.lua diff --git a/types/bitmap.d.lua b/edgetx-lua-stdlib/bitmap.d.lua similarity index 100% rename from types/bitmap.d.lua rename to edgetx-lua-stdlib/bitmap.d.lua diff --git a/types/dir.d.lua b/edgetx-lua-stdlib/dir.d.lua similarity index 100% rename from types/dir.d.lua rename to edgetx-lua-stdlib/dir.d.lua diff --git a/types/edgetx.d.lua b/edgetx-lua-stdlib/edgetx.d.lua similarity index 100% rename from types/edgetx.d.lua rename to edgetx-lua-stdlib/edgetx.d.lua diff --git a/types/hardware.d.lua b/edgetx-lua-stdlib/hardware.d.lua similarity index 100% rename from types/hardware.d.lua rename to edgetx-lua-stdlib/hardware.d.lua diff --git a/types/lcd.d.lua b/edgetx-lua-stdlib/lcd.d.lua similarity index 100% rename from types/lcd.d.lua rename to edgetx-lua-stdlib/lcd.d.lua diff --git a/types/lua-runtime.d.lua b/edgetx-lua-stdlib/lua-runtime.d.lua similarity index 100% rename from types/lua-runtime.d.lua rename to edgetx-lua-stdlib/lua-runtime.d.lua diff --git a/types/lvgl.d.lua b/edgetx-lua-stdlib/lvgl.d.lua similarity index 100% rename from types/lvgl.d.lua rename to edgetx-lua-stdlib/lvgl.d.lua diff --git a/types/model.d.lua b/edgetx-lua-stdlib/model.d.lua similarity index 100% rename from types/model.d.lua rename to edgetx-lua-stdlib/model.d.lua diff --git a/types/runtime.d.lua b/edgetx-lua-stdlib/runtime.d.lua similarity index 100% rename from types/runtime.d.lua rename to edgetx-lua-stdlib/runtime.d.lua diff --git a/types/serial.d.lua b/edgetx-lua-stdlib/serial.d.lua similarity index 100% rename from types/serial.d.lua rename to edgetx-lua-stdlib/serial.d.lua diff --git a/types/telemetry.d.lua b/edgetx-lua-stdlib/telemetry.d.lua similarity index 100% rename from types/telemetry.d.lua rename to edgetx-lua-stdlib/telemetry.d.lua diff --git a/types/time.d.lua b/edgetx-lua-stdlib/time.d.lua similarity index 100% rename from types/time.d.lua rename to edgetx-lua-stdlib/time.d.lua diff --git a/types/widget.d.lua b/edgetx-lua-stdlib/widget.d.lua similarity index 100% rename from types/widget.d.lua rename to edgetx-lua-stdlib/widget.d.lua From bad38ddb8de9ae0d302231886bbeefeddd841ade Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 14:16:48 +0200 Subject: [PATCH 067/218] fix workflow --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5f10f6..614b298 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,10 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] + workflow_dispatch: jobs: check: From 2f6a55c2dd626c6582dde820bad7855d8276bba7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 4 Mar 2026 14:19:40 +0200 Subject: [PATCH 068/218] update github action runtime --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 614b298..e17fe93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [master] + branches: [ master ] pull_request: - branches: [master] + branches: [ master ] workflow_dispatch: jobs: @@ -12,10 +12,10 @@ jobs: name: Lint and Typecheck runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: ~/.cargo key: cargo-stylua-${{ runner.os }} From 683c6ef760594f98a4c147f403fc36a56983bc04 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 5 Mar 2026 13:41:03 +0200 Subject: [PATCH 069/218] add sync command --- Makefile | 6 +++++- edgetx.toml | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 edgetx.toml diff --git a/Makefile b/Makefile index b9e0d46..ddd2818 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LUALS_DIR := bin/lua-language-server LUALS := $(LUALS_DIR)/bin/lua-language-server LUALS_URL := https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-linux-x64.tar.gz -.PHONY: help install-tools install-stylua install-luals format format-check typecheck check +.PHONY: help install-tools install-stylua install-luals format format-check typecheck check sync help: @echo "Usage: make " @@ -20,6 +20,7 @@ help: @echo " format-check Check formatting without modifying files" @echo " typecheck Run lua-language-server type checking" @echo " check Run format-check and typecheck" + @echo " sync Sync source files to EdgeTX simulator SD card" install-stylua: @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } @@ -41,3 +42,6 @@ typecheck: $(LUALS) --check . check: format-check typecheck + +sync: + edgetx sync ../edgetx-sdcard diff --git a/edgetx.toml b/edgetx.toml new file mode 100644 index 0000000..69a0d67 --- /dev/null +++ b/edgetx.toml @@ -0,0 +1,24 @@ +[package] +name = "expresslrs" +version = "1.0.0" +description = "ExpressLRS Lua scripts and widgets for EdgeTX" +source_dir = "src" + +[[libraries]] +name = "ELRS" +path = "SCRIPTS/ELRS" + +[[tools]] +name = "ExpressLRS" +path = "SCRIPTS/TOOLS/ExpressLRS" + +[[widgets]] +name = "ELRSTelemetry" +path = "WIDGETS/ELRSTelemetry" +depends = ["ELRS"] + +[[widgets]] +name = "ELRSVTXAdmin" +path = "WIDGETS/ELRSVTXAdmin" +depends = ["ELRS"] +exclude = ["presets.txt"] From 5dec8f93ba4930f86f7d6535d50405f83ab3bf6e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 5 Mar 2026 15:24:38 +0200 Subject: [PATCH 070/218] update make file --- Makefile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index ddd2818..da69c7c 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ LUALS_DIR := bin/lua-language-server LUALS := $(LUALS_DIR)/bin/lua-language-server LUALS_URL := https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-linux-x64.tar.gz -.PHONY: help install-tools install-stylua install-luals format format-check typecheck check sync +.PHONY: help install-tools install-stylua install-luals format format-check typecheck check sync push help: @echo "Usage: make " @@ -21,6 +21,7 @@ help: @echo " typecheck Run lua-language-server type checking" @echo " check Run format-check and typecheck" @echo " sync Sync source files to EdgeTX simulator SD card" + @echo " push Push source files to EdgeTX radio and eject" install-stylua: @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } @@ -44,4 +45,7 @@ typecheck: check: format-check typecheck sync: - edgetx sync ../edgetx-sdcard + edgetx dev sync ../edgetx-sdcard + +push: + edgetx dev push --eject From 3ca29dfb50f407a1c7e58b67fdcadc7534db1f81 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 09:01:17 +0200 Subject: [PATCH 071/218] Fix LVGL focus loss on field change by avoiding full UI rebuild --- .gitignore | 3 +- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 5 + src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 29 +++++- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 4 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 114 ++++++++++++---------- 5 files changed, 96 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index deb2d87..d979cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.luac color/WIDGETS/ELRSVTXAdmin/presets.txt -bin/ \ No newline at end of file +bin/ +.claude/plans/ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 5fe5885..1892988 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -189,6 +189,11 @@ local function run(event, touchState) end UI.folderWasReady = folderReady + if Protocol.fieldHiddenChanged then + Protocol.fieldHiddenChanged = nil + UI.visibleFields = nil + end + UI.render(event, touchState) if App.shouldExit then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 7e10085..aea3590 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -276,7 +276,7 @@ function Protocol.getFieldsInFolder(folderId) local result = {} for _, childId in ipairs(folder.children) do local child = Protocol.fields[childId] - if child and child.name and not child.hidden then + if child and child.name then result[#result + 1] = child end end @@ -426,10 +426,24 @@ end function Protocol.fieldTextSelLoad(field, data, offset) local vcnt - local cached = field.dirty == nil and field.values + local oldValues = field.values + local cached = field.dirty == nil and oldValues field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) if not cached then field.disabled = (vcnt <= 1) or nil + -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) + if oldValues and #oldValues == #field.values then + local same = true + for i = 1, #field.values do + if oldValues[i] ~= field.values[i] then + same = false + break + end + end + if same then + field.values = oldValues + end + end end field.value = data[offset] local unit = Protocol.fieldGetStrOrOpts(data, offset + 4) @@ -527,13 +541,13 @@ function Protocol.reloadRelatedFields(field) and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) then sibling.dirty = true - sibling.name = nil + sibling.reloading = true Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId end end field.dirty = true - field.name = nil + field.reloading = true Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id Protocol.fieldTimeout = getTime() + 20 Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 @@ -654,10 +668,15 @@ function Protocol.parseParameterInfoMessage(data) field.id = fieldId field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + local wasHidden = field.hidden field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil - local cachedName = (not field.nameStale) and field.name or nil + if field.hidden ~= wasHidden then + Protocol.fieldHiddenChanged = true + end + local cachedName = (not field.nameStale and not field.reloading) and field.name or nil field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) field.nameStale = nil + field.reloading = nil local handler = Protocol.handlers[field.type + 1] if handler and handler.load then handler.load(field, Protocol.fieldData, offset) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index f335da0..21c09cc 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -258,7 +258,9 @@ function UI.buildVisibleFields() else local fields = Protocol.getFieldsInFolder(currentFolder) for _, field in ipairs(fields) do - vf[#vf + 1] = field + if not field.hidden then + vf[#vf + 1] = field + end end if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 00332a6..c590bef 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -733,6 +733,7 @@ function UI.createToggleRow(pg, field) pg:setting({ w = lvgl.PERCENT_SIZE + 100, title = field.name, + visible = function() return not field.hidden end, children = { { type = lvgl.BOX, @@ -762,7 +763,7 @@ function UI.createToggleRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_MEDIUM, - text = field.unit, + text = function() return field.unit or "" end, }, }, }, @@ -773,55 +774,59 @@ function UI.createToggleRow(pg, field) end function UI.createChoiceRow(pg, field) - local filteredValues = {} - local origToFiltered = {} - local filteredToOrig = {} - for i, v in ipairs(field.values or {}) do - if v ~= "" then - filteredValues[#filteredValues + 1] = v - origToFiltered[i - 1] = #filteredValues - filteredToOrig[#filteredValues] = i - 1 - end - end + local valuesRef = field.values + local choiceWidget - pg:setting({ + local setting = pg:setting({ w = lvgl.PERCENT_SIZE + 100, title = field.name, - children = { - { - type = lvgl.BOX, - x = LABEL_PCT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - align = LEFT, - children = { - { - type = lvgl.CHOICE, - title = field.name, - values = filteredValues, - get = function() - return origToFiltered[field.value or 0] or 1 - end, - set = function(val) - field.value = filteredToOrig[val] or 0 - Protocol.fieldIntSave(field) - Protocol.reloadRelatedFields(field) - end, - active = function() - return not field.disabled - end, - }, - { - type = lvgl.BOX, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_MEDIUM, - text = field.unit, - }, - }, - }, + visible = function() + if field.hidden then return false end + if field.values ~= valuesRef then + valuesRef = field.values + if choiceWidget then + choiceWidget:set({ values = field.values or {} }) + end + end + return true + end, + }) + + local valueBox = setting:box({ + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + }) + + choiceWidget = valueBox:choice({ + title = field.name, + values = field.values or {}, + filter = function(index) + return (field.values and field.values[index] or "") ~= "" + end, + get = function() + return (field.value or 0) + 1 + end, + set = function(val) + field.value = val - 1 + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() + return not field.disabled + end, + }) + + valueBox:build({ + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = function() return field.unit or "" end, }, }, }, @@ -874,7 +879,7 @@ function UI.createNumberRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_MEDIUM, - text = field.unit, + text = function() return field.unit or "" end, }, }, }, @@ -889,6 +894,7 @@ function UI.createNumberRow(pg, field) pg:setting({ w = lvgl.PERCENT_SIZE + 100, title = field.name, + visible = function() return not field.hidden end, children = children, }) end @@ -899,11 +905,12 @@ function UI.createInfoRow(pg, field) type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, title = field.name, + visible = function() return not field.hidden end, children = { { type = lvgl.LABEL, x = LABEL_PCT, - text = field.value, + text = function() return field.value or "" end, }, }, }, @@ -916,6 +923,7 @@ function UI.createStringRow(pg, field) type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, title = field.name, + visible = function() return not field.hidden end, children = { { type = lvgl.TEXT_EDIT, @@ -939,7 +947,8 @@ end function UI.createFolderWidget(pg, field, width) pg:button({ - text = field.name or "", + text = function() return field.name or "" end, + visible = function() return not field.hidden end, w = width or (lvgl.PERCENT_SIZE + 100), h = lvgl.UI_ELEMENT_HEIGHT * 2, press = function() @@ -954,9 +963,10 @@ function UI.createCommandWidget(pg, field) flexFlow = lvgl.FLOW_COLUMN, align = CENTER, borderPad = { top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }, + visible = function() return not field.hidden end, }) wrapper:button({ - text = field.name or "", + text = function() return field.name or "" end, w = lvgl.PERCENT_SIZE + 99, press = function() Protocol.handleCommandSave(field) @@ -965,7 +975,7 @@ function UI.createCommandWidget(pg, field) end function UI.buildFieldWidget(pg, field, folderWidth) - if not field or not field.name then + if not field then return end From 7acf5306ece13ecb4fae873cd26e6d46a4298f2e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 09:12:37 +0200 Subject: [PATCH 072/218] count reloading fields as missing progress --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index aea3590..6a7eab2 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -249,7 +249,7 @@ function Protocol.getFolderLoadProgress(folderId) local loaded = 0 for _, childId in ipairs(folder.children) do local child = Protocol.fields[childId] - if child and child.name then + if child and child.name and not child.reloading then loaded = loaded + 1 end end From a38d8b1e9656ce77bdc29b7d17028f0213574b00 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 16:00:53 +0200 Subject: [PATCH 073/218] migrate to edgetx-cli pkg* commands --- Makefile | 6 +++--- edgetx.toml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index da69c7c..39978be 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ help: @echo " typecheck Run lua-language-server type checking" @echo " check Run format-check and typecheck" @echo " sync Sync source files to EdgeTX simulator SD card" - @echo " push Push source files to EdgeTX radio and eject" + @echo " push Install package to EdgeTX radio and eject" install-stylua: @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } @@ -45,7 +45,7 @@ typecheck: check: format-check typecheck sync: - edgetx dev sync ../edgetx-sdcard + edgetx-cli dev sync ../edgetx-sdcard push: - edgetx dev push --eject + edgetx-cli pkg install . --eject diff --git a/edgetx.toml b/edgetx.toml index 69a0d67..24b3a6a 100644 --- a/edgetx.toml +++ b/edgetx.toml @@ -1,6 +1,5 @@ [package] name = "expresslrs" -version = "1.0.0" description = "ExpressLRS Lua scripts and widgets for EdgeTX" source_dir = "src" From ebf6fc914d05072c7ca244b1693be98666362437 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 16:21:55 +0200 Subject: [PATCH 074/218] add license --- edgetx.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/edgetx.toml b/edgetx.toml index 24b3a6a..24c73d3 100644 --- a/edgetx.toml +++ b/edgetx.toml @@ -1,6 +1,7 @@ [package] name = "expresslrs" description = "ExpressLRS Lua scripts and widgets for EdgeTX" +license = "GPL-3.0" source_dir = "src" [[libraries]] From e892fb3b010d22a1807a71cb93a3a1dcab42ea18 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 17:18:14 +0200 Subject: [PATCH 075/218] add min edgetx version --- edgetx.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/edgetx.toml b/edgetx.toml index 24c73d3..654f921 100644 --- a/edgetx.toml +++ b/edgetx.toml @@ -3,6 +3,7 @@ name = "expresslrs" description = "ExpressLRS Lua scripts and widgets for EdgeTX" license = "GPL-3.0" source_dir = "src" +min_edgetx_version = "2.12.0" [[libraries]] name = "ELRS" From 35568a0a1e2a28a5faafae4ea8c126c9993428cc Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 17:34:55 +0200 Subject: [PATCH 076/218] migrate from toml to yml for consistency with edgetx --- edgetx.toml | 25 ------------------------- edgetx.yml | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 25 deletions(-) delete mode 100644 edgetx.toml create mode 100644 edgetx.yml diff --git a/edgetx.toml b/edgetx.toml deleted file mode 100644 index 654f921..0000000 --- a/edgetx.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "expresslrs" -description = "ExpressLRS Lua scripts and widgets for EdgeTX" -license = "GPL-3.0" -source_dir = "src" -min_edgetx_version = "2.12.0" - -[[libraries]] -name = "ELRS" -path = "SCRIPTS/ELRS" - -[[tools]] -name = "ExpressLRS" -path = "SCRIPTS/TOOLS/ExpressLRS" - -[[widgets]] -name = "ELRSTelemetry" -path = "WIDGETS/ELRSTelemetry" -depends = ["ELRS"] - -[[widgets]] -name = "ELRSVTXAdmin" -path = "WIDGETS/ELRSVTXAdmin" -depends = ["ELRS"] -exclude = ["presets.txt"] diff --git a/edgetx.yml b/edgetx.yml new file mode 100644 index 0000000..138ab05 --- /dev/null +++ b/edgetx.yml @@ -0,0 +1,27 @@ +package: + name: expresslrs + description: ExpressLRS Lua scripts and widgets for EdgeTX + license: GPL-3.0 + source_dir: src + min_edgetx_version: "2.12.0" + +libraries: + - name: ELRS + path: SCRIPTS/ELRS + +tools: + - name: ExpressLRS + path: SCRIPTS/TOOLS/ExpressLRS + +widgets: + - name: ELRSTelemetry + path: WIDGETS/ELRSTelemetry + depends: + - ELRS + + - name: ELRSVTXAdmin + path: WIDGETS/ELRSVTXAdmin + depends: + - ELRS + exclude: + - presets.txt From 1366aea5fedbe2c43615fc8286688e9a53225045 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 6 Mar 2026 18:24:35 +0200 Subject: [PATCH 077/218] fix code style --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 52 ++++++++++++++++++------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index c590bef..c6549f4 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -733,7 +733,9 @@ function UI.createToggleRow(pg, field) pg:setting({ w = lvgl.PERCENT_SIZE + 100, title = field.name, - visible = function() return not field.hidden end, + visible = function() + return not field.hidden + end, children = { { type = lvgl.BOX, @@ -763,7 +765,9 @@ function UI.createToggleRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_MEDIUM, - text = function() return field.unit or "" end, + text = function() + return field.unit or "" + end, }, }, }, @@ -781,7 +785,9 @@ function UI.createChoiceRow(pg, field) w = lvgl.PERCENT_SIZE + 100, title = field.name, visible = function() - if field.hidden then return false end + if field.hidden then + return false + end if field.values ~= valuesRef then valuesRef = field.values if choiceWidget then @@ -826,7 +832,9 @@ function UI.createChoiceRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_MEDIUM, - text = function() return field.unit or "" end, + text = function() + return field.unit or "" + end, }, }, }, @@ -879,7 +887,9 @@ function UI.createNumberRow(pg, field) { type = lvgl.LABEL, y = lvgl.PAD_MEDIUM, - text = function() return field.unit or "" end, + text = function() + return field.unit or "" + end, }, }, }, @@ -894,7 +904,9 @@ function UI.createNumberRow(pg, field) pg:setting({ w = lvgl.PERCENT_SIZE + 100, title = field.name, - visible = function() return not field.hidden end, + visible = function() + return not field.hidden + end, children = children, }) end @@ -905,12 +917,16 @@ function UI.createInfoRow(pg, field) type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, title = field.name, - visible = function() return not field.hidden end, + visible = function() + return not field.hidden + end, children = { { type = lvgl.LABEL, x = LABEL_PCT, - text = function() return field.value or "" end, + text = function() + return field.value or "" + end, }, }, }, @@ -923,7 +939,9 @@ function UI.createStringRow(pg, field) type = lvgl.SETTING, w = lvgl.PERCENT_SIZE + 100, title = field.name, - visible = function() return not field.hidden end, + visible = function() + return not field.hidden + end, children = { { type = lvgl.TEXT_EDIT, @@ -947,8 +965,12 @@ end function UI.createFolderWidget(pg, field, width) pg:button({ - text = function() return field.name or "" end, - visible = function() return not field.hidden end, + text = function() + return field.name or "" + end, + visible = function() + return not field.hidden + end, w = width or (lvgl.PERCENT_SIZE + 100), h = lvgl.UI_ELEMENT_HEIGHT * 2, press = function() @@ -963,10 +985,14 @@ function UI.createCommandWidget(pg, field) flexFlow = lvgl.FLOW_COLUMN, align = CENTER, borderPad = { top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }, - visible = function() return not field.hidden end, + visible = function() + return not field.hidden + end, }) wrapper:button({ - text = function() return field.name or "" end, + text = function() + return field.name or "" + end, w = lvgl.PERCENT_SIZE + 99, press = function() Protocol.handleCommandSave(field) From fb8c77ac279932d3a91d5b5aa9d538437ed7fa31 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 7 Mar 2026 00:04:58 +0200 Subject: [PATCH 078/218] update readme --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 42ba4f3..8a678fe 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,16 @@ WIDGETS/ The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. +### Install with edgetx-cli + +You can also install this package using [edgetx-cli](https://github.com/jurgelenas/edgetx-cli): + +```sh +edgetx-cli pkg install ExpressLRS/Lua-Scripts@unified-lua-lsp +``` + +Use the `--eject` flag to automatically unmount the SD card after installation. + ## ExpressLRS Configuration Tool The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. From 6e51c93187ba81858e9edc69d7c33bbef53ff40a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 7 Mar 2026 00:14:38 +0200 Subject: [PATCH 079/218] Move CRSFSimulator to src/ --- edgetx.yml | 3 +++ {test => src}/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 0 2 files changed, 3 insertions(+) rename {test => src}/SCRIPTS/CRSFSimulator/csrfsimulator.lua (100%) diff --git a/edgetx.yml b/edgetx.yml index 138ab05..a2386bd 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -9,6 +9,9 @@ libraries: - name: ELRS path: SCRIPTS/ELRS + - name: CRSFSimulator + path: SCRIPTS/CRSFSimulator + tools: - name: ExpressLRS path: SCRIPTS/TOOLS/ExpressLRS diff --git a/test/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua similarity index 100% rename from test/SCRIPTS/CRSFSimulator/csrfsimulator.lua rename to src/SCRIPTS/CRSFSimulator/csrfsimulator.lua From 747c55a8e6f43a527c27f25be87370eadaeb04d6 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 7 Mar 2026 00:27:20 +0200 Subject: [PATCH 080/218] mark CRSFSimulator as dev depency --- edgetx.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/edgetx.yml b/edgetx.yml index a2386bd..005ee9f 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -11,6 +11,7 @@ libraries: - name: CRSFSimulator path: SCRIPTS/CRSFSimulator + dev: true tools: - name: ExpressLRS From 91037485717ffdd8577dbe0c7b96cfb858a484a6 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 7 Mar 2026 00:31:15 +0200 Subject: [PATCH 081/218] remove test dir from lua checklist --- Makefile | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 39978be..b7f3d9a 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,4 @@ SRC_DIR := src -TEST_DIR := test - -LUA_DIRS := $(SRC_DIR) $(TEST_DIR) LUALS_VERSION := 3.17.1 LUALS_DIR := bin/lua-language-server @@ -34,10 +31,10 @@ install-luals: install-tools: install-stylua install-luals format: - stylua $(LUA_DIRS) + stylua $(SRC_DIR) format-check: - stylua --check $(LUA_DIRS) + stylua --check $(SRC_DIR) typecheck: $(LUALS) --check . From 3ea2862aa4e4fcd39983223347824de9d8506413 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 16 Mar 2026 15:09:05 +0200 Subject: [PATCH 082/218] Add GPL-3.0 license --- LICENSE | 674 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 674 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. From 71f7d8bcaa71f015ec46c1979f37bec2d176882f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 16 Mar 2026 15:13:21 +0200 Subject: [PATCH 083/218] Suppress deprecated diagnostic in bitmap.d.lua The @deprecated annotations are intentional for consumers, but LuaLS flags them as warnings when checking the library itself. --- edgetx-lua-stdlib/bitmap.d.lua | 2 ++ 1 file changed, 2 insertions(+) diff --git a/edgetx-lua-stdlib/bitmap.d.lua b/edgetx-lua-stdlib/bitmap.d.lua index 628a965..f3d887b 100644 --- a/edgetx-lua-stdlib/bitmap.d.lua +++ b/edgetx-lua-stdlib/bitmap.d.lua @@ -39,6 +39,8 @@ function bitmap.resize(bmp, width, height) end ---@return string mask Binary mask data function bitmap.toMask(bmp) end +---@diagnostic disable: deprecated + --- @deprecated Use `bitmap` module instead. ---@class Bitmap Bitmap = {} From 3a20099f6b7c3b24b87d04154c7bce5d37b264ce Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 16 Mar 2026 15:20:20 +0200 Subject: [PATCH 084/218] remove edgetx-lua-stdlib --- edgetx-lua-stdlib/audio.d.lua | 56 -- edgetx-lua-stdlib/bit32.d.lua | 67 --- edgetx-lua-stdlib/bitmap.d.lua | 69 --- edgetx-lua-stdlib/dir.d.lua | 58 -- edgetx-lua-stdlib/edgetx.d.lua | 396 ------------- edgetx-lua-stdlib/hardware.d.lua | 43 -- edgetx-lua-stdlib/lcd.d.lua | 528 ----------------- edgetx-lua-stdlib/lua-runtime.d.lua | 94 --- edgetx-lua-stdlib/lvgl.d.lua | 848 ---------------------------- edgetx-lua-stdlib/model.d.lua | 352 ------------ edgetx-lua-stdlib/runtime.d.lua | 22 - edgetx-lua-stdlib/serial.d.lua | 28 - edgetx-lua-stdlib/telemetry.d.lua | 180 ------ edgetx-lua-stdlib/time.d.lua | 19 - edgetx-lua-stdlib/widget.d.lua | 43 -- 15 files changed, 2803 deletions(-) delete mode 100644 edgetx-lua-stdlib/audio.d.lua delete mode 100644 edgetx-lua-stdlib/bit32.d.lua delete mode 100644 edgetx-lua-stdlib/bitmap.d.lua delete mode 100644 edgetx-lua-stdlib/dir.d.lua delete mode 100644 edgetx-lua-stdlib/edgetx.d.lua delete mode 100644 edgetx-lua-stdlib/hardware.d.lua delete mode 100644 edgetx-lua-stdlib/lcd.d.lua delete mode 100644 edgetx-lua-stdlib/lua-runtime.d.lua delete mode 100644 edgetx-lua-stdlib/lvgl.d.lua delete mode 100644 edgetx-lua-stdlib/model.d.lua delete mode 100644 edgetx-lua-stdlib/runtime.d.lua delete mode 100644 edgetx-lua-stdlib/serial.d.lua delete mode 100644 edgetx-lua-stdlib/telemetry.d.lua delete mode 100644 edgetx-lua-stdlib/time.d.lua delete mode 100644 edgetx-lua-stdlib/widget.d.lua diff --git a/edgetx-lua-stdlib/audio.d.lua b/edgetx-lua-stdlib/audio.d.lua deleted file mode 100644 index fec57cd..0000000 --- a/edgetx-lua-stdlib/audio.d.lua +++ /dev/null @@ -1,56 +0,0 @@ ----@meta - --- EdgeTX audio and haptic playback functions --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Play an audio file from the SD card. ---- @since 2.0.0 ----@param path string Audio file path (e.g. "/SOUNDS/en/hello.wav") ----@param volume? integer Volume override (1-5, omit to use system setting) -function playFile(path, volume) end - ---- Play a number with optional unit announcement. ---- @since 2.0.0 ----@param value integer Number to play ----@param unit integer Unit constant (UNIT_*) ----@param attributes? integer ----@param volume? integer Volume override (1-5, omit to use system setting) -function playNumber(value, unit, attributes, volume) end - ---- Play a duration value (hours, minutes, seconds). ---- @since 2.0.0 ----@param duration integer Seconds ----@param playTime? integer Non-zero to announce as time of day ----@param volume? integer Volume override (1-5, omit to use system setting) -function playDuration(duration, playTime, volume) end - ---- Play a tone with configurable frequency, duration, and pattern. ---- @since 2.1.0 ----@param frequency integer Hz ----@param duration integer ms ----@param pause integer ms of silence after tone ----@param flags? integer 0 or PLAY_NOW ----@param freqIncr? integer Frequency increment per repeat ----@param volume? integer Volume override (1-5, omit to use system setting) -function playTone(frequency, duration, pause, flags, freqIncr, volume) end - ---- Generate haptic feedback. ---- @since 2.2.0 ----@param duration integer ms ----@param pause? integer ms of silence after haptic ----@param flags? integer 0 or PLAY_NOW -function playHaptic(duration, pause, flags) end - ---- Flush the audio queue, stopping all current and pending audio playback. -function flushAudio() end - --- ========================================================================== --- Audio constants --- ========================================================================== - ----@type integer -PLAY_NOW = 0 ----@type integer -PLAY_BACKGROUND = 0 ----@type integer -TIMEHOUR = 0 diff --git a/edgetx-lua-stdlib/bit32.d.lua b/edgetx-lua-stdlib/bit32.d.lua deleted file mode 100644 index 4ce0156..0000000 --- a/edgetx-lua-stdlib/bit32.d.lua +++ /dev/null @@ -1,67 +0,0 @@ ----@meta - --- Lua 5.2 bit32 library (available in EdgeTX runtime) --- Custom definition to avoid deprecation warnings from lua-language-server. --- The built-in bit32 is disabled in .luarc.json because LuaLS marks it --- deprecated under Lua 5.3, but EdgeTX's runtime still requires it. - ----@class bit32lib -bit32 = {} - ----@param ... integer ----@return integer -function bit32.band(...) end - ----@param ... integer ----@return integer -function bit32.bor(...) end - ----@param ... integer ----@return integer -function bit32.bxor(...) end - ----@param x integer ----@return integer -function bit32.bnot(x) end - ----@param ... integer ----@return boolean -function bit32.btest(...) end - ----@param x integer ----@param disp integer ----@return integer -function bit32.lshift(x, disp) end - ----@param x integer ----@param disp integer ----@return integer -function bit32.rshift(x, disp) end - ----@param x integer ----@param disp integer ----@return integer -function bit32.arshift(x, disp) end - ----@param x integer ----@param disp integer ----@return integer -function bit32.lrotate(x, disp) end - ----@param x integer ----@param disp integer ----@return integer -function bit32.rrotate(x, disp) end - ----@param n integer ----@param field integer ----@param width? integer ----@return integer -function bit32.extract(n, field, width) end - ----@param n integer ----@param v integer ----@param field integer ----@param width? integer ----@return integer -function bit32.replace(n, v, field, width) end diff --git a/edgetx-lua-stdlib/bitmap.d.lua b/edgetx-lua-stdlib/bitmap.d.lua deleted file mode 100644 index f3d887b..0000000 --- a/edgetx-lua-stdlib/bitmap.d.lua +++ /dev/null @@ -1,69 +0,0 @@ ----@meta - --- EdgeTX bitmap module (color LCD only) --- Source: edgetx/radio/src/lua/api_colorlcd.cpp - ----@class BitmapObject - ----@class bitmap -bitmap = {} - ---- Load a bitmap from the SD card. Bitmaps should be loaded only once; store the ---- returned object for later drawing with lcd.drawBitmap(). If loading fails the ---- resulting bitmap object will have width and height of zero. ---- Loading can fail if the file is not found, the image is invalid, ---- the system is low on memory, or combined Lua bitmap usage exceeds the limit. ---- @since 2.2.0 ----@param path string Full path to the bitmap on SD card (e.g. "/IMAGES/test.bmp") ----@return BitmapObject bmp -function bitmap.open(path) end - ---- Return width and height of a bitmap object. ---- @since 2.2.0 ----@param bmp BitmapObject Bitmap previously opened with bitmap.open() ----@return integer width Width in pixels ----@return integer height Height in pixels -function bitmap.getSize(bmp) end - ---- Return a new resized copy of a bitmap. ---- @since 2.8.0 ----@param bmp BitmapObject Bitmap previously opened with bitmap.open() ----@param width integer New width in pixels ----@param height integer New height in pixels ----@return BitmapObject|nil resized nil if input bitmap is invalid -function bitmap.resize(bmp, width, height) end - ---- Convert a bitmap to an 8-bit mask for use with lcd.drawBitmapPattern(). ---- @since 2.8.0 ----@param bmp BitmapObject Bitmap previously opened with bitmap.open() ----@return string mask Binary mask data -function bitmap.toMask(bmp) end - ----@diagnostic disable: deprecated - ---- @deprecated Use `bitmap` module instead. ----@class Bitmap -Bitmap = {} - ---- @deprecated Use `bitmap.open` instead. ----@param path string Full path to the bitmap on SD card ----@return BitmapObject bmp -function Bitmap.open(path) end - ---- @deprecated Use `bitmap.getSize` instead. ----@param bmp BitmapObject ----@return integer width ----@return integer height -function Bitmap.getSize(bmp) end - ---- @deprecated Use `bitmap.resize` instead. ----@param bmp BitmapObject ----@param width integer ----@param height integer ----@return BitmapObject|nil resized -function Bitmap.resize(bmp, width, height) end - ---- @deprecated Use `bitmap.toMask` instead. ----@param bmp BitmapObject ----@return string mask -function Bitmap.toMask(bmp) end diff --git a/edgetx-lua-stdlib/dir.d.lua b/edgetx-lua-stdlib/dir.d.lua deleted file mode 100644 index 90d243f..0000000 --- a/edgetx-lua-stdlib/dir.d.lua +++ /dev/null @@ -1,58 +0,0 @@ ----@meta - --- EdgeTX filesystem module --- Source: edgetx/radio/src/lua/api_filesystem.cpp - ----@class dir -dir = {} - ---- Return an iterator listing all file and directory names in a directory. ---- @since 2.5.0 ----@param path? string Working directory ----@return fun():string|nil iterator Returns filenames one at a time -function dir.dir(path) end - ---- Check the existence of a file or directory. Returns nil if not found. ---- @since 2.5.0 ----@param path? string Path to the object ----@return {size: integer, attrib: integer, time: {year: integer, mon: integer, day: integer, hour: integer, hour12: integer, min: integer, sec: integer, suffix: string}}|nil stat -function dir.fstat(path) end - ---- Delete a file or directory. ---- @since 2.9.0 ----@param path? string Path to file or directory to delete ----@return integer result FRESULT (0=OK, 4=File not found, 5=Path not found, 6=Path invalid) -function dir.del(path) end - ---- Change the working directory. ---- @since 2.3.0 ----@param path? string New working directory -function dir.chdir(path) end - ---- Create a directory. ---- @since 2.11.0 ----@param path string Directory path to create ----@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) -function dir.mkdir(path) end - ---- Rename or move a file or directory. The new parent directory must already exist. ---- @since 2.11.0 ----@param oldPath string Current path ----@param newPath string New path ----@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) -function dir.rename(oldPath, newPath) end - --- ========================================================================== --- File attribute constants (for dir.fstat attrib field) --- ========================================================================== - ----@type integer -AM_RDO = 0 ----@type integer -AM_HID = 0 ----@type integer -AM_SYS = 0 ----@type integer -AM_DIR = 0 ----@type integer -AM_ARC = 0 diff --git a/edgetx-lua-stdlib/edgetx.d.lua b/edgetx-lua-stdlib/edgetx.d.lua deleted file mode 100644 index f719577..0000000 --- a/edgetx-lua-stdlib/edgetx.d.lua +++ /dev/null @@ -1,396 +0,0 @@ ----@meta - --- EdgeTX global functions and constants --- Source: edgetx/radio/src/lua/api_general.cpp - --- ========================================================================== --- System information --- ========================================================================== - ---- Return EdgeTX version information. ---- @since 2.0.0 ----@return string version Full version string (e.g. "2.11.0") ----@return string radio Radio type string (e.g. "tx16s-2.11.0") ----@return integer major Major version number ----@return integer minor Minor version number ----@return integer revision Revision number ----@return string osname OS name (e.g. "EdgeTX") -function getVersion() end - ---- Return general radio settings. ---- @since 2.0.0 ----@return {battWarn: number, battMin: number, battMax: number, imperial: integer, language: string, voice: string, gtimer: integer} settings -function getGeneralSettings() end - ---- Return radio global timer data. ---- @since 2.3.0 ----@return {total: integer, session: integer, throttle: integer, throttlepct: integer} -function getGlobalTimer() end - ---- Reset one or all radio global timers. ---- @since 2.3.0 ----@param which? string "total"|"session"|"throttle"|"throttlepct" (default "total") -function resetGlobalTimer(which) end - ---- Return rotary encoder speed. ----@return integer speed ROTENC_LOWSPEED, ROTENC_MIDSPEED, or ROTENC_HIGHSPEED -function getRotEncSpeed() end - ---- Return rotary encoder mode. ----@return integer mode -function getRotEncMode() end - ---- Return the current stick mode (1-4). ----@return integer mode -function getStickMode() end - --- ========================================================================== --- Source and value functions --- ========================================================================== - ---- Return the current value of a source. GPS and cells return tables. ---- @since 2.0.0 ----@param source integer|string Source index or name ----@return integer|number|string|table|nil value -function getValue(source) end - ---- Return the current output value for a channel (after all processing). ----@param index integer Channel index (0-based) ----@return integer value -function getOutputValue(index) end - ---- Return the value of a source with freshness information. ----@param source integer|string Source index or name ----@return integer|number|string|table|nil value ----@return boolean isCurrent True if telemetry stream is currently active ----@return boolean isFresh True if data has been recently updated -function getSourceValue(source) end - ---- Return information about a field/source. Returns nil if source not found. ---- For telemetry sources, the `unit` field is also included. ---- @since 2.0.8 ----@param source integer|string Source index or name ----@return {id: integer, name: string, desc: string, unit?: integer}|nil info -function getFieldInfo(source) end - ---- Return information about a source. Same as getFieldInfo(). ----@param source integer|string Source index or name ----@return {id: integer, name: string, desc: string, unit?: integer}|nil info -function getSourceInfo(source) end - ---- Get the source index for a named source. ----@param name string Source name ----@return integer|nil index -function getSourceIndex(name) end - ---- Get the display name for a source index. ----@param index integer Source index ----@return string|nil name -function getSourceName(index) end - ---- Return an iterator over sources. Optional first/last to limit range. ----@param first? integer Starting source index ----@param last? integer Ending source index ----@return function iterator -function sources(first, last) end - --- ========================================================================== --- Switch functions --- ========================================================================== - ---- Set a function switch (sticky switch) state. ----@param index integer Switch index ----@param value boolean ----@return boolean bufferFull True if the command buffer is full -function setStickySwitch(index, value) end - ---- Get the value of a logical switch. ----@param index integer Logical switch index (0-based) ----@return boolean|nil value nil if index is invalid -function getLogicalSwitchValue(index) end - ---- Get information about a physical switch position. ---- @since 2.6.0 ----@param index integer Switch source index (MIXSRC_FIRST_SWITCH-based) ----@return {type: integer, isCustomisableSwitch: boolean, name: string}|nil info -function getSwitchInfo(index) end - ---- Get the switch index for a named switch position. ---- @since 2.6.0 ----@param name string Switch position name (as shown in radio menus) ----@return integer|nil index -function getSwitchIndex(name) end - ---- Get the display name for a switch index. ---- @since 2.6.0 ----@param index integer Switch index ----@return string|nil name -function getSwitchName(index) end - ---- Get the current value of a switch. ----@param index integer Switch index ----@return boolean|nil value nil if index is invalid -function getSwitchValue(index) end - ---- Return an iterator over switch positions. Optional first/last to limit range. ----@param first? integer Starting switch index ----@param last? integer Ending switch index ----@return function iterator -function switches(first, last) end - --- ========================================================================== --- Stick and channel functions --- ========================================================================== - ---- Return the default stick index for a given channel. ---- @since 2.0.0 ----@param channel integer Channel number (0-3) ----@return integer stick Stick index (0-3) -function defaultStick(channel) end - ---- Return the default channel for a given stick. Returns nil if not found. ---- @since 2.0.0 ----@param stick integer Stick index (0-3) ----@return integer|nil channel -function defaultChannel(stick) end - --- ========================================================================== --- Flight mode --- ========================================================================== - ---- Return the current flight mode number and name. ---- @since 2.1.7 ----@param index? integer Flight mode index (default: current active mode) ----@return integer mode ----@return string name -function getFlightMode(index) end - --- ========================================================================== --- UI / popup functions --- ========================================================================== - ---- Take a screenshot and save to SD card. -function screenshot() end - ---- Show an input popup dialog with adjustable numeric value. ---- @since 2.0.0 ----@param title string ----@param event integer ----@param input integer Current value ----@param min integer Minimum value ----@param max integer Maximum value ----@return integer|string result "OK", "CANCEL", or current input value -function popupInput(title, event, input, min, max) end - ---- Show a warning popup. Returns "CANCEL" when dismissed, nil while still showing. ---- @since 2.0.0 ----@param title string ----@param event integer ----@return string|nil result "CANCEL" when dismissed, nil while still showing -function popupWarning(title, event) end - ---- Show a confirmation popup with OK/Cancel buttons. ---- @since 2.0.0 ----@overload fun(title: string, event: integer): string|nil @Deprecated 2-arg form ----@param title string ----@param message string ----@param event? integer ----@return string|nil result "OK", "CANCEL", or nil while still showing -function popupConfirmation(title, message, event) end - ---- Stop key repeat events for the given key. ----@param event integer Key event to kill -function killEvents(event) end - --- ========================================================================== --- Touch event constants (color LCD with touch) --- ========================================================================== - ----@type integer -EVT_TOUCH_FIRST = 0 ----@type integer -EVT_TOUCH_BREAK = 0 ----@type integer -EVT_TOUCH_SLIDE = 0 ----@type integer -EVT_TOUCH_TAP = 0 - --- ========================================================================== --- Virtual event constants --- ========================================================================== - ----@type integer -EVT_VIRTUAL_PREV = 0 ----@type integer -EVT_VIRTUAL_PREV_REPT = 0 ----@type integer -EVT_VIRTUAL_NEXT = 0 ----@type integer -EVT_VIRTUAL_NEXT_REPT = 0 ----@type integer -EVT_VIRTUAL_DEC = 0 ----@type integer -EVT_VIRTUAL_DEC_REPT = 0 ----@type integer -EVT_VIRTUAL_INC = 0 ----@type integer -EVT_VIRTUAL_INC_REPT = 0 ----@type integer -EVT_VIRTUAL_PREV_PAGE = 0 ----@type integer -EVT_VIRTUAL_NEXT_PAGE = 0 ----@type integer -EVT_VIRTUAL_MENU = 0 ----@type integer -EVT_VIRTUAL_MENU_LONG = 0 ----@type integer -EVT_VIRTUAL_ENTER = 0 ----@type integer -EVT_VIRTUAL_ENTER_LONG = 0 ----@type integer -EVT_VIRTUAL_EXIT = 0 ----@type integer -EVT_EXIT_BREAK = 0 - --- ========================================================================== --- Key event constants (generated per-radio, superset listed) --- ========================================================================== - ----@type integer -EVT_ENTER_FIRST = 0 ----@type integer -EVT_ENTER_BREAK = 0 ----@type integer -EVT_ENTER_LONG = 0 ----@type integer -EVT_ENTER_REPT = 0 ----@type integer -EVT_MENU_FIRST = 0 ----@type integer -EVT_MENU_BREAK = 0 ----@type integer -EVT_MENU_LONG = 0 ----@type integer -EVT_MENU_REPT = 0 ----@type integer -EVT_TELEM_FIRST = 0 ----@type integer -EVT_TELEM_BREAK = 0 ----@type integer -EVT_TELEM_LONG = 0 ----@type integer -EVT_TELEM_REPT = 0 ----@type integer -EVT_MODEL_FIRST = 0 ----@type integer -EVT_MODEL_BREAK = 0 ----@type integer -EVT_MODEL_LONG = 0 ----@type integer -EVT_MODEL_REPT = 0 ----@type integer -EVT_SYS_FIRST = 0 ----@type integer -EVT_SYS_BREAK = 0 ----@type integer -EVT_SYS_LONG = 0 ----@type integer -EVT_SYS_REPT = 0 ----@type integer -EVT_UP_FIRST = 0 ----@type integer -EVT_UP_BREAK = 0 ----@type integer -EVT_UP_LONG = 0 ----@type integer -EVT_UP_REPT = 0 ----@type integer -EVT_DOWN_FIRST = 0 ----@type integer -EVT_DOWN_BREAK = 0 ----@type integer -EVT_DOWN_LONG = 0 ----@type integer -EVT_DOWN_REPT = 0 ----@type integer -EVT_LEFT_FIRST = 0 ----@type integer -EVT_LEFT_BREAK = 0 ----@type integer -EVT_LEFT_LONG = 0 ----@type integer -EVT_LEFT_REPT = 0 ----@type integer -EVT_RIGHT_FIRST = 0 ----@type integer -EVT_RIGHT_BREAK = 0 ----@type integer -EVT_RIGHT_LONG = 0 ----@type integer -EVT_RIGHT_REPT = 0 ----@type integer -EVT_PAGEUP_FIRST = 0 ----@type integer -EVT_PAGEUP_BREAK = 0 ----@type integer -EVT_PAGEUP_LONG = 0 ----@type integer -EVT_PAGEUP_REPT = 0 ----@type integer -EVT_PAGEDN_FIRST = 0 ----@type integer -EVT_PAGEDN_BREAK = 0 ----@type integer -EVT_PAGEDN_LONG = 0 ----@type integer -EVT_PAGEDN_REPT = 0 ----@type integer -EVT_PAGE_FIRST = 0 ----@type integer -EVT_PAGE_BREAK = 0 ----@type integer -EVT_PAGE_LONG = 0 ----@type integer -EVT_PAGE_REPT = 0 ----@type integer -EVT_PLUS_FIRST = 0 ----@type integer -EVT_PLUS_BREAK = 0 ----@type integer -EVT_PLUS_LONG = 0 ----@type integer -EVT_PLUS_REPT = 0 ----@type integer -EVT_MINUS_FIRST = 0 ----@type integer -EVT_MINUS_BREAK = 0 ----@type integer -EVT_MINUS_LONG = 0 ----@type integer -EVT_MINUS_REPT = 0 ----@type integer -EVT_SHIFT_FIRST = 0 ----@type integer -EVT_SHIFT_BREAK = 0 ----@type integer -EVT_SHIFT_LONG = 0 ----@type integer -EVT_SHIFT_REPT = 0 ----@type integer -EVT_ROT_FIRST = 0 ----@type integer -EVT_ROT_BREAK = 0 ----@type integer -EVT_ROT_LONG = 0 ----@type integer -EVT_ROT_REPT = 0 ----@type integer -EVT_ROT_LEFT = 0 ----@type integer -EVT_ROT_RIGHT = 0 ----@type integer -ROTENC_LOWSPEED = 0 ----@type integer -ROTENC_MIDSPEED = 0 ----@type integer -ROTENC_HIGHSPEED = 0 diff --git a/edgetx-lua-stdlib/hardware.d.lua b/edgetx-lua-stdlib/hardware.d.lua deleted file mode 100644 index 6789b6b..0000000 --- a/edgetx-lua-stdlib/hardware.d.lua +++ /dev/null @@ -1,43 +0,0 @@ ----@meta - --- EdgeTX LED and IMU hardware functions --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Set the color of an RGB LED in the strip. ----@param index integer LED index (0-based) ----@param red integer 0-255 ----@param green integer 0-255 ----@param blue integer 0-255 ----@return boolean success -function setRGBLedColor(index, red, green, blue) end - ---- Apply all pending RGB LED color changes. -function applyRGBLedColors() end - ---- Set or cancel a CFS (Customizable Function Switch) LED color override. ---- Call with only the name to cancel the override. ----@param name string Switch name ----@param red? integer 0-255 (omit all color args to cancel override) ----@param green? integer 0-255 ----@param blue? integer 0-255 ----@return boolean success -function setCFSLedColor(name, red, green, blue) end - ---- Set the IMU X-axis calibration parameters. ----@param offset integer Degrees (-180 to 180) ----@param range integer Degrees (0 to 180) ----@return boolean success -function setIMU_X(offset, range) end - ---- Set the IMU Y-axis calibration parameters. ----@param offset integer Degrees (-180 to 180) ----@param range integer Degrees (0 to 180) ----@return boolean success -function setIMU_Y(offset, range) end - --- ========================================================================== --- Hardware constants --- ========================================================================== - ----@type integer -LED_STRIP_LENGTH = 0 diff --git a/edgetx-lua-stdlib/lcd.d.lua b/edgetx-lua-stdlib/lcd.d.lua deleted file mode 100644 index a067adc..0000000 --- a/edgetx-lua-stdlib/lcd.d.lua +++ /dev/null @@ -1,528 +0,0 @@ ----@meta - --- EdgeTX LCD drawing module --- Source: edgetx/radio/src/lua/api_colorlcd.cpp, api_stdlcd.cpp - ----@class lcd -lcd = {} - ---- Refresh the LCD screen. On color LCDs (2.4.0+) this is done automatically. ---- @since 2.0.0 -function lcd.refresh() end - ---- Clear the LCD screen. ---- @since 2.0.0 ----@param color? integer Fill color (color LCD only, defaults to COLOR_THEME_SECONDARY3) -function lcd.clear(color) end - ---- Reset the backlight timeout. ---- @since 2.3.6 -function lcd.resetBacklightTimeout() end - ---- Draw a single pixel at (x,y). ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param flags? integer Drawing flags -function lcd.drawPoint(x, y, flags) end - ---- Draw a straight line between two points. ---- If either endpoint is outside the LCD, the whole line will not be drawn. ---- @since 2.0.0 ----@param x1 integer ----@param y1 integer ----@param x2 integer ----@param y2 integer ----@param pattern integer SOLID or DOTTED ----@param flags? integer Drawing flags -function lcd.drawLine(x1, y1, x2, y2, pattern, flags) end - ---- Draw a rectangle outline. ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param w integer Width in pixels ----@param h integer Height in pixels ----@param flags? integer Drawing flags ----@param thickness? integer Border thickness in pixels (color LCD only, default 1) ----@param opacity? integer 0-15 (color LCD only, default 0) -function lcd.drawRectangle(x, y, w, h, flags, thickness, opacity) end - ---- Draw a solid filled rectangle. ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param w integer Width in pixels ----@param h integer Height in pixels ----@param flags? integer Drawing flags ----@param opacity? integer 0-15 (color LCD only, default 0) -function lcd.drawFilledRectangle(x, y, w, h, flags, opacity) end - ---- Invert a rectangular region. Color LCD only. ---- @since 2.8.0 ----@param x integer ----@param y integer ----@param w integer ----@param h integer ----@param flags? integer Drawing flags -function lcd.invertRect(x, y, w, h, flags) end - ---- Draw a text string at (x,y). ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param text string ----@param flags? integer Drawing flags (font size, alignment, color, INVERS, BLINK, SHADOWED, etc.) ----@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set -function lcd.drawText(x, y, text, flags, inversColor) end - ---- Draw text inside a bounding rectangle with automatic line breaks. ---- Color LCD only. RIGHT, CENTER, VCENTER are not implemented. ---- @since 2.5.0 ----@param x integer ----@param y integer ----@param w integer ----@param h integer ----@param text string ----@param flags? integer Drawing flags ----@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set ----@return integer x X position where text drawing ended ----@return integer y Y position where text drawing ended -function lcd.drawTextLines(x, y, w, h, text, flags, inversColor) end - ---- Get the width and height of a text string when drawn with the given flags. ---- Color LCD only. ---- @since 2.5.0 ----@param text string ----@param flags? integer Drawing flags (font size, etc.) ----@return integer width ----@return integer height -function lcd.sizeText(text, flags) end - ---- Display a value formatted as time at (x,y). ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param value integer Time in seconds ----@param flags? integer Drawing flags (use TIMEHOUR to show hours) ----@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set -function lcd.drawTimer(x, y, value, flags, inversColor) end - ---- Display a number at (x,y). Use PREC1/PREC2 flags for decimal precision. ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param value integer ----@param flags? integer Drawing flags ----@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set -function lcd.drawNumber(x, y, value, flags, inversColor) end - ---- Display a telemetry value at (x,y). ---- @since 2.0.6 ----@param x integer ----@param y integer ----@param source integer|string Source index or name (only telemetry sources are valid) ----@param flags? integer Drawing flags -function lcd.drawChannel(x, y, source, flags) end - ---- Draw the text representation of a switch at (x,y). ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param switch integer Switch index; negative number shows negated switch ----@param flags? integer Drawing flags -function lcd.drawSwitch(x, y, switch, flags) end - ---- Display the name of the corresponding source at (x,y). ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param source integer Source index ----@param flags? integer Drawing flags -function lcd.drawSource(x, y, source, flags) end - ---- Draw a horizontal gauge bar. ---- @since 2.0.0 ----@param x integer ----@param y integer ----@param w integer Width in pixels ----@param h integer Height in pixels ----@param fill integer Current fill amount ----@param maxfill integer Maximum fill value ----@param flags? integer Drawing flags -function lcd.drawGauge(x, y, w, h, fill, maxfill, flags) end - ---- Draw a .bmp pixmap at (x,y). B&W LCD only; on color LCD use lcd.drawBitmap(). ----@param x integer ----@param y integer ----@param name string File path on SD card -function lcd.drawPixmap(x, y, name) end - ---- Draw a title bar with page counter. B&W LCD only. ----@param title string ----@param page integer Current page (1-based) ----@param pages integer Total number of pages -function lcd.drawScreenTitle(title, page, pages) end - ---- Draw a combobox (dropdown selector). B&W LCD only. ----@param x integer ----@param y integer ----@param w integer Width in pixels ----@param list table Array of option strings ----@param idx integer Selected index ----@param flags integer Drawing flags -function lcd.drawCombobox(x, y, w, list, idx, flags) end - ---- Display a bitmap at (x,y). Color LCD only. ---- Omitting scale draws at 1:1 and is faster than specifying 100. ---- @since 2.2.0 ----@param bmp BitmapObject Bitmap opened with bitmap.open() ----@param x integer ----@param y integer ----@param scale? integer Percentage (0 or omit = 1:1, 50 = half, 200 = double) -function lcd.drawBitmap(bmp, x, y, scale) end - ---- Display a bitmap pattern (mask) at (x,y) using the current color. Color LCD only. ---- @since 2.8.0 ----@param mask string Binary mask data from bitmap.toMask() ----@param x integer ----@param y integer ----@param flags? integer Drawing flags (color) -function lcd.drawBitmapPattern(mask, x, y, flags) end - ---- Display a pie-shaped slice of a bitmap pattern. Color LCD only. ---- @since 2.8.0 ----@param mask string Binary mask data from bitmap.toMask() ----@param x integer ----@param y integer ----@param startAngle integer ----@param endAngle integer ----@param flags? integer Drawing flags (color) -function lcd.drawBitmapPatternPie(mask, x, y, startAngle, endAngle, flags) end - ---- Change an indexed color (theme colors or CUSTOM_COLOR). ---- Changing theme colors affects all widgets and the radio UI. Color LCD only. ---- @since 2.2.0 ----@param colorIndex integer One of the COLOR_THEME_* or CUSTOM_COLOR constants ----@param color integer RGB color value (from lcd.RGB() or another color constant) -function lcd.setColor(colorIndex, color) end - ---- Get the RGB565 color value from a color flag. Color LCD only. ---- @since 2.3.11 ----@param colorIndex integer Color constant or flags containing color ----@return integer|nil color RGB565 color value, or nil for invalid input -function lcd.getColor(colorIndex) end - ---- Create an RGB color flag from components. Color LCD only. ---- Can be called as lcd.RGB(r, g, b) or lcd.RGB(rgb). ---- @since 2.2.0 ----@overload fun(rgb: integer): integer ----@param r integer 0-255 ----@param g integer 0-255 ----@param b integer 0-255 ----@return integer color Color flag with RGB565 value -function lcd.RGB(r, g, b) end - ---- Draw a circle outline. Color LCD only. ---- @since 2.4.0 ----@param x integer Center X ----@param y integer Center Y ----@param r integer Radius in pixels ----@param flags? integer Drawing flags -function lcd.drawCircle(x, y, r, flags) end - ---- Draw a filled circle. Color LCD only. ---- @since 2.4.0 ----@param x integer Center X ----@param y integer Center Y ----@param r integer Radius in pixels ----@param flags? integer Drawing flags -function lcd.drawFilledCircle(x, y, r, flags) end - ---- Draw a triangle outline. Color LCD only. ---- @since 2.4.0 ----@param x1 integer ----@param y1 integer ----@param x2 integer ----@param y2 integer ----@param x3 integer ----@param y3 integer ----@param flags? integer Drawing flags -function lcd.drawTriangle(x1, y1, x2, y2, x3, y3, flags) end - ---- Draw a filled triangle. Color LCD only. ---- @since 2.4.0 ----@param x1 integer ----@param y1 integer ----@param x2 integer ----@param y2 integer ----@param x3 integer ----@param y3 integer ----@param flags? integer Drawing flags -function lcd.drawFilledTriangle(x1, y1, x2, y2, x3, y3, flags) end - ---- Draw an arc (ring segment). Color LCD only. ---- @since 2.4.0 ----@param x integer Center X ----@param y integer Center Y ----@param r integer Radius in pixels ----@param startAngle integer ----@param endAngle integer ----@param flags? integer Drawing flags -function lcd.drawArc(x, y, r, startAngle, endAngle, flags) end - ---- Draw a filled pie slice. Color LCD only. ---- @since 2.4.0 ----@param x integer Center X ----@param y integer Center Y ----@param r integer Radius in pixels ----@param startAngle integer ----@param endAngle integer ----@param flags? integer Drawing flags -function lcd.drawPie(x, y, r, startAngle, endAngle, flags) end - ---- Draw a filled annulus (ring sector). Color LCD only. ---- @since 2.4.0 ----@param x integer Center X ----@param y integer Center Y ----@param rInner integer Inner radius ----@param rOuter integer Outer radius ----@param startAngle integer ----@param endAngle integer ----@param flags? integer Drawing flags -function lcd.drawAnnulus(x, y, rInner, rOuter, startAngle, endAngle, flags) end - ---- Draw a line clipped to a rectangle. Color LCD only. ---- @since 2.4.0 ----@param x1 integer ----@param y1 integer ----@param x2 integer ----@param y2 integer ----@param xmin integer Clip region left ----@param xmax integer Clip region right ----@param ymin integer Clip region top ----@param ymax integer Clip region bottom ----@param pattern integer FORCE, ERASE, or DOTTED ----@param flags? integer Drawing flags -function lcd.drawLineWithClipping(x1, y1, x2, y2, xmin, xmax, ymin, ymax, pattern, flags) end - ---- Draw a HUD-style horizon rectangle with pitch and roll perspective. Color LCD only. ---- @since 2.4.0 ----@param pitch number Pitch angle in degrees ----@param roll number Roll angle in degrees ----@param xmin integer ----@param xmax integer ----@param ymin integer ----@param ymax integer ----@param flags? integer Drawing flags -function lcd.drawHudRectangle(pitch, roll, xmin, xmax, ymin, ymax, flags) end - ---- Exit full screen widget mode. Color LCD only. ---- @since 2.5.0 -function lcd.exitFullScreen() end - ---- Get the X position after the last lcd.drawText(). B&W LCD only. ----@return integer x -function lcd.getLastPos() end - ---- Get the right edge X position after the last lcd.drawText(). B&W LCD only. ----@return integer x -function lcd.getLastRightPos() end - ---- Get the left edge X position after the last lcd.drawText(). B&W LCD only. ----@return integer x -function lcd.getLastLeftPos() end - --- ========================================================================== --- Display helper functions --- ========================================================================== - ---- Convert a value (0-15) to a grey color flag. B&W LCD only (not color LCD). ----@param value integer Grey level (0-15) ----@return integer color -function GREY(value) end - --- ========================================================================== --- Display flag constants --- ========================================================================== - ----@type integer -FULLSCALE = 0 ----@type integer -STDSIZE = 0 ----@type integer -XXLSIZE = 0 ----@type integer -DBLSIZE = 0 ----@type integer -MIDSIZE = 0 ----@type integer -SMLSIZE = 0 ----@type integer -TINSIZE = 0 ----@type integer -BOLD = 0 ----@type integer -BLINK = 0 ----@type integer -INVERS = 0 ----@type integer -VCENTER = 0 ----@type integer -VTOP = 0 ----@type integer -VBOTTOM = 0 ----@type integer -RIGHT = 0 ----@type integer -LEFT = 0 ----@type integer -CENTER = 0 ----@type integer -PREC1 = 0 ----@type integer -PREC2 = 0 ----@type integer -SHADOWED = 0 ----@type integer -FIXEDWIDTH = 0 - --- ========================================================================== --- Color constants (color LCD only) --- ========================================================================== - ----@type integer -COLOR_THEME_PRIMARY1 = 0 ----@type integer -COLOR_THEME_PRIMARY2 = 0 ----@type integer -COLOR_THEME_PRIMARY3 = 0 ----@type integer -COLOR_THEME_SECONDARY1 = 0 ----@type integer -COLOR_THEME_SECONDARY2 = 0 ----@type integer -COLOR_THEME_SECONDARY3 = 0 ----@type integer -COLOR_THEME_FOCUS = 0 ----@type integer -COLOR_THEME_EDIT = 0 ----@type integer -COLOR_THEME_ACTIVE = 0 ----@type integer -COLOR_THEME_WARNING = 0 ----@type integer -COLOR_THEME_DISABLED = 0 ----@type integer -CUSTOM_COLOR = 0 ----@type integer -BLACK = 0 ----@type integer -WHITE = 0 ----@type integer -LIGHTWHITE = 0 ----@type integer -LIGHTGREY = 0 ----@type integer -DARKGREY = 0 ----@type integer -RED = 0 ----@type integer -DARKRED = 0 ----@type integer -LIGHTRED = 0 ----@type integer -GREEN = 0 ----@type integer -DARKGREEN = 0 ----@type integer -BRIGHTGREEN = 0 ----@type integer -BLUE = 0 ----@type integer -DARKBLUE = 0 ----@type integer -CYAN = 0 ----@type integer -YELLOW = 0 ----@type integer -LIGHTBROWN = 0 ----@type integer -DARKBROWN = 0 ----@type integer -ORANGE = 0 ----@type integer -MAGENTA = 0 - --- ========================================================================== --- Drawing pattern constants --- ========================================================================== - ----@type integer -SOLID = 0 ----@type integer -DOTTED = 0 ----@type integer -FILL_WHITE = 0 ----@type integer -GREY_DEFAULT = 0 ----@type integer -FORCE = 0 ----@type integer -ERASE = 0 ----@type integer -ROUND = 0 - --- ========================================================================== --- Screen dimension constants --- ========================================================================== - ----@type integer -LCD_W = 0 ----@type integer -LCD_H = 0 ----@type integer -MENU_HEADER_HEIGHT = 0 - --- ========================================================================== --- Special character constants (light userdata) --- ========================================================================== - ----@type any -CHAR_RIGHT = nil ----@type any -CHAR_LEFT = nil ----@type any -CHAR_UP = nil ----@type any -CHAR_DOWN = nil ----@type any -CHAR_DELTA = nil ----@type any -CHAR_STICK = nil ----@type any -CHAR_POT = nil ----@type any -CHAR_SLIDER = nil ----@type any -CHAR_SWITCH = nil ----@type any -CHAR_TRIM = nil ----@type any -CHAR_INPUT = nil ----@type any -CHAR_FUNCTION = nil ----@type any -CHAR_CYC = nil ----@type any -CHAR_TRAINER = nil ----@type any -CHAR_CHANNEL = nil ----@type any -CHAR_TELEMETRY = nil ----@type any -CHAR_LUA = nil ----@type any -CHAR_LS = nil ----@type any -CHAR_CURVE = nil diff --git a/edgetx-lua-stdlib/lua-runtime.d.lua b/edgetx-lua-stdlib/lua-runtime.d.lua deleted file mode 100644 index cef07ac..0000000 --- a/edgetx-lua-stdlib/lua-runtime.d.lua +++ /dev/null @@ -1,94 +0,0 @@ ----@meta - --- EdgeTX Lua runtime overrides --- Only libraries that differ from standard Lua 5.3 are redefined here. --- string, math (core), bit32, table, and base globals use LuaLS builtins. - --- ========================================================================== --- io library (5 functions only, non-standard signatures) --- EdgeTX uses FatFs; all functions take a file handle as the first argument. --- No file methods (:read, :write, etc.), no io.lines, io.input, io.output. --- ========================================================================== - ----@class file* - -io = {} - ---- Open a file on the SD card. Returns file handle on success, or nil + error ---- message on failure. ----@param filename string Full path on SD card ----@param mode? "r"|"rb"|"w"|"wb"|"a"|"ab" Default "r". Only the first character matters; 'b' is accepted but ignored. ----@return file*|nil handle ----@return string|nil errmsg -function io.open(filename, mode) end - ---- Read up to `size` bytes from the file. Returns an empty string at end of file. ----@param file file* ----@param size integer Number of bytes to read ----@return string data Empty string on EOF -function io.read(file, size) end - ---- Write values to the file. Accepts strings and numbers. ----@param file file* ----@param ... string|number ----@return file* file The file handle on success -function io.write(file, ...) end - ---- Close the file handle. ----@param file file* -function io.close(file) end - ---- Seek to an absolute byte offset in the file. ----@param file file* ----@param offset integer Absolute byte offset (unsigned) ----@return integer result FRESULT (0 = OK) -function io.seek(file, offset) end - --- ========================================================================== --- math compat functions (LUA_COMPAT_5_2 / LUA_COMPAT_MATHLIB) --- These are deprecated in standard Lua 5.3 but available in EdgeTX. --- ========================================================================== - ----@deprecated Use `math.atan(y, x)` instead ----@param y number ----@param x? number ----@return number -function math.atan2(y, x) end - ----@deprecated ----@param x number ----@return number -function math.cosh(x) end - ----@deprecated ----@param x number ----@return number -function math.sinh(x) end - ----@deprecated ----@param x number ----@return number -function math.tanh(x) end - ----@deprecated Use `x ^ y` instead ----@param x number ----@param y number ----@return number -function math.pow(x, y) end - ----@deprecated ----@param x number ----@return number m ----@return integer e -function math.frexp(x) end - ----@deprecated ----@param m number ----@param e integer ----@return number -function math.ldexp(m, e) end - ----@deprecated Use `math.log(x, 10)` instead ----@param x number ----@return number -function math.log10(x) end diff --git a/edgetx-lua-stdlib/lvgl.d.lua b/edgetx-lua-stdlib/lvgl.d.lua deleted file mode 100644 index e9f67b3..0000000 --- a/edgetx-lua-stdlib/lvgl.d.lua +++ /dev/null @@ -1,848 +0,0 @@ ----@meta - --- EdgeTX LVGL module (color LCD only) --- Source: edgetx/radio/src/lua/api_colorlcd_lvgl.cpp - ----@class LvglObject -local LvglObject = {} - ----@param settings LvglLabelSettings ----@return LvglObject -function LvglObject:label(settings) end - ----@param settings LvglRectangleSettings ----@return LvglObject -function LvglObject:rectangle(settings) end - ----@param settings LvglBoxSettings ----@return LvglObject -function LvglObject:box(settings) end - ----@param settings LvglButtonSettings ----@return LvglObject -function LvglObject:button(settings) end - ----@param settings LvglMomentaryButtonSettings ----@return LvglObject -function LvglObject:momentaryButton(settings) end - ----@param settings LvglToggleSettings ----@return LvglObject -function LvglObject:toggle(settings) end - ----@param settings LvglTextEditSettings ----@return LvglObject -function LvglObject:textEdit(settings) end - ----@param settings LvglNumberEditSettings ----@return LvglObject -function LvglObject:numberEdit(settings) end - ----@param settings LvglChoiceSettings ----@return LvglObject -function LvglObject:choice(settings) end - ----@param settings LvglSliderSettings ----@return LvglObject -function LvglObject:slider(settings) end - ----@param settings LvglSettingSettings ----@return LvglObject -function LvglObject:setting(settings) end - ----@param settings LvglHlineSettings ----@return LvglObject -function LvglObject:hline(settings) end - ----@param settings LvglVlineSettings ----@return LvglObject -function LvglObject:vline(settings) end - ----@param settings LvglLineSettings ----@return LvglObject -function LvglObject:line(settings) end - ----@param settings LvglTriangleSettings ----@return LvglObject -function LvglObject:triangle(settings) end - ----@param settings LvglCircleSettings ----@return LvglObject -function LvglObject:circle(settings) end - ----@param settings LvglArcSettings ----@return LvglObject -function LvglObject:arc(settings) end - ----@param settings LvglImageSettings ----@return LvglObject -function LvglObject:image(settings) end - ----@param settings LvglQrcodeSettings ----@return LvglObject -function LvglObject:qrcode(settings) end - ----@param settings LvglSourceSettings ----@return LvglObject -function LvglObject:source(settings) end - ----@param settings LvglSwitchSettings ----@return LvglObject -function LvglObject:switch(settings) end - -function LvglObject:show() end -function LvglObject:hide() end -function LvglObject:enable() end -function LvglObject:disable() end -function LvglObject:close() end -function LvglObject:clear() end - ----@param tree table[] ----@return table -function LvglObject:build(tree) end - ----@param settings table -function LvglObject:set(settings) end - ----@return integer x ----@return integer y -function LvglObject:getScrollPos() end - --- ========================================================================== --- Common settings (inherited by all widgets via LvglWidgetObjectBase) --- ========================================================================== - ----@class LvglCommonSettings ----@field x? integer X position (or PERCENT_SIZE+N for percentage) ----@field y? integer Y position ----@field w? integer Width (0 = auto size to fit content, or PERCENT_SIZE+N) ----@field h? integer Height (0 = auto size to fit content, or PERCENT_SIZE+N) ----@field color? integer|fun():integer Primary color ----@field opacity? integer|fun():integer 0-255 ----@field visible? fun():boolean Dynamic visibility ----@field size? fun(w: integer, h: integer): integer, integer Dynamic size callback ----@field pos? fun(): integer, integer Dynamic position callback - --- ========================================================================== --- Page --- ========================================================================== - ---- Full-screen page layout with header bar, title, and optional navigation. ----@class LvglPageSettings ----@field title? string|fun():string Header title text ----@field subtitle? string|fun():string Header subtitle text ----@field icon? string Path to 30x30 grayscale mask image ----@field back? fun() Called on back button / RTN key ----@field menu? fun() Called on menu button ----@field prevButton? {press: fun(), active?: fun():boolean} ----@field nextButton? {press: fun(), active?: fun():boolean} ----@field backButton? boolean Show exit/back button in header right ----@field flexFlow? integer FLOW_ROW or FLOW_COLUMN ----@field flexPad? integer Padding between flex items ----@field scrollBar? boolean ----@field scrollDir? integer SCROLL_OFF, SCROLL_HOR, SCROLL_VER, SCROLL_ALL ----@field scrolled? fun(x: integer, y: integer) ----@field scrollTo? fun(): integer, integer ----@field align? integer Flex layout alignment (LEFT, CENTER, RIGHT, VTOP, VCENTER, VBOTTOM) ----@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} ----@field active? fun():boolean - --- ========================================================================== --- Dialog --- ========================================================================== - ---- Modal dialog with a title bar and close button. ----@class LvglDialogSettings : LvglCommonSettings ----@field title? string|fun():string ----@field flexFlow? integer ----@field flexPad? integer ----@field close? fun() Called when dialog is closed ----@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} ----@field active? fun():boolean - --- ========================================================================== --- Confirm / Message popups --- ========================================================================== - ---- Yes/No confirmation dialog. ----@class LvglConfirmSettings ----@field title? string|fun():string ----@field message? string ----@field confirm? fun() Called when Yes is tapped ----@field cancel? fun() Called when No is tapped - ---- Information message dialog. ----@class LvglMessageSettings ----@field title? string|fun():string ----@field message? string ----@field details? string Additional detail text - --- ========================================================================== --- Menu --- ========================================================================== - ---- Popup menu for selecting from a list of options. ----@class LvglMenuSettings ----@field title? string|fun():string ----@field values? string[] List of menu item strings ----@field get? fun():integer Current selection index (1-based) ----@field set? fun(index: integer) Selection changed - --- ========================================================================== --- Label --- ========================================================================== - ---- Display a text label. ----@class LvglLabelSettings : LvglCommonSettings ----@field text? string|fun():string ----@field font? integer|fun():integer Font flag (BOLD, SMLSIZE, MIDSIZE, DBLSIZE, etc.) ----@field align? integer|fun():integer Text alignment (LEFT, CENTER, RIGHT, VCENTER, etc.) ----@field children? table[] - --- ========================================================================== --- Rectangle --- ========================================================================== - ---- Display a rectangle (outline or filled). ----@class LvglRectangleSettings : LvglCommonSettings ----@field thickness? integer Border thickness (0 = invisible border) ----@field filled? boolean|fun():boolean ----@field rounded? integer Corner radius (must be >= thickness if > 0) ----@field flexFlow? integer ----@field flexPad? integer ----@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} ----@field align? integer|fun():integer ----@field scrollBar? boolean ----@field scrollDir? integer ----@field scrolled? fun(x: integer, y: integer) ----@field scrollTo? fun(): integer, integer ----@field active? fun():boolean ----@field children? table[] - --- ========================================================================== --- Box (layout container) --- ========================================================================== - ---- Invisible container for managing child layout. ----@class LvglBoxSettings : LvglCommonSettings ----@field flexFlow? integer FLOW_ROW or FLOW_COLUMN ----@field flexPad? integer Padding between flex items ----@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} ----@field align? integer|fun():integer ----@field scrollBar? boolean ----@field scrollDir? integer ----@field scrolled? fun(x: integer, y: integer) ----@field scrollTo? fun(): integer, integer ----@field active? fun():boolean ----@field children? table[] - --- ========================================================================== --- Button --- ========================================================================== - ---- Text button using the EdgeTX style. ----@class LvglButtonSettings : LvglCommonSettings ----@field text? string|fun():string ----@field font? integer|fun():integer ----@field cornerRadius? integer ----@field textColor? integer|fun():integer ----@field press? fun() Called when button is tapped ----@field longpress? fun() Called on long press ----@field checked? boolean Initial checked state ----@field active? fun():boolean - --- ========================================================================== --- Momentary Button --- ========================================================================== - ---- Momentary button that fires on press and release. ----@class LvglMomentaryButtonSettings : LvglCommonSettings ----@field text? string|fun():string ----@field font? integer|fun():integer ----@field cornerRadius? integer ----@field textColor? integer|fun():integer ----@field press? fun() Called on press ----@field release? fun() Called on release ----@field active? fun():boolean - --- ========================================================================== --- Toggle --- ========================================================================== - ---- Toggle switch using the EdgeTX style. ----@class LvglToggleSettings : LvglCommonSettings ----@field get? fun():integer Returns 0 or 1 ----@field set? fun(value: integer) Receives 0 or 1 ----@field active? fun():boolean - --- ========================================================================== --- Text Edit --- ========================================================================== - ---- Text input field. ----@class LvglTextEditSettings : LvglCommonSettings ----@field value? string Initial text value ----@field length? integer Max text length (1-128, default 32) ----@field set? fun(value: string) Called when text is changed ----@field active? fun():boolean - --- ========================================================================== --- Number Edit --- ========================================================================== - ---- Numeric input field with +/- adjustment. ----@class LvglNumberEditSettings : LvglCommonSettings ----@field min? integer Default -1024 ----@field max? integer Default 1024 ----@field get? fun():integer ----@field set? fun(value: integer) Called on user interaction ----@field edited? fun(value: integer) Called when editing is committed ----@field display? fun(value: integer):string Custom display formatting ----@field active? fun():boolean - --- ========================================================================== --- Choice (dropdown) --- ========================================================================== - ---- Button that opens a popup menu for selecting from a list. ----@class LvglChoiceSettings : LvglCommonSettings ----@field title? string|fun():string Popup title ----@field values? string[] List of option strings ----@field get? fun():integer Current selection index (1-based) ----@field set? fun(index: integer) Selection changed (receives 1..n) ----@field filter? fun(index: integer):boolean Filter callback for available options ----@field popupWidth? integer Width of the choice popup ----@field active? fun():boolean - --- ========================================================================== --- Slider --- ========================================================================== - ---- Horizontal slider. ----@class LvglSliderSettings : LvglCommonSettings ----@field min? integer Default 0 ----@field max? integer Default 100 ----@field get? fun():integer ----@field set? fun(value: integer) Called on user interaction ----@field active? fun():boolean - --- ========================================================================== --- Setting (label + control container) --- ========================================================================== - ---- A labeled row containing a title and child controls. ----@class LvglSettingSettings : LvglCommonSettings ----@field title? string|fun():string ----@field flexFlow? integer ----@field flexPad? integer ----@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} ----@field active? fun():boolean ----@field children? table[] - --- ========================================================================== --- Source selector --- ========================================================================== - ---- Source picker control. ----@class LvglSourceSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field filter? integer Bitmask of SRC_* constants ----@field active? fun():boolean - --- ========================================================================== --- Switch selector --- ========================================================================== - ---- Switch picker control. ----@class LvglSwitchSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field filter? integer Bitmask of SW_* constants ----@field active? fun():boolean - --- ========================================================================== --- File picker --- ========================================================================== - ---- File selection control. ----@class LvglFileSettings : LvglCommonSettings ----@field title? string|fun():string ----@field folder? string Folder path ----@field extension? string File extension filter ----@field maxLen? integer Maximum filename length ----@field hideExtension? boolean Hide extension in display ----@field get? fun():string ----@field set? fun(value: string) ----@field active? fun():boolean - --- ========================================================================== --- Font picker --- ========================================================================== - ---- Font selection control. ----@class LvglFontSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field active? fun():boolean - --- ========================================================================== --- Align picker --- ========================================================================== - ---- Alignment selection control. ----@class LvglAlignSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field active? fun():boolean - --- ========================================================================== --- Color picker --- ========================================================================== - ---- Color selection control. ----@class LvglColorSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field active? fun():boolean - --- ========================================================================== --- Timer picker --- ========================================================================== - ---- Timer selection control. ----@class LvglTimerSettings : LvglCommonSettings ----@field get? fun():integer ----@field set? fun(value: integer) ----@field active? fun():boolean - --- ========================================================================== --- Geometry primitives --- ========================================================================== - ---- Horizontal line. ----@class LvglHlineSettings : LvglCommonSettings ----@field thickness? integer ----@field rounded? boolean Round end caps ----@field dashGap? integer Gap size for dashed lines ----@field dashWidth? integer Dash size for dashed lines - ---- Vertical line. ----@class LvglVlineSettings : LvglCommonSettings ----@field thickness? integer ----@field rounded? boolean Round end caps ----@field dashGap? integer ----@field dashWidth? integer - ---- One or more connected line segments. ----@class LvglLineSettings : LvglCommonSettings ----@field pts? table|fun():table Array of {x, y} points (at least 2) ----@field thickness? integer ----@field rounded? boolean Round end caps - ---- Filled triangle defined by 3 points. ----@class LvglTriangleSettings : LvglCommonSettings ----@field pts? table|fun():table Array of 3 {x, y} points - ---- Circle (outline or filled). ----@class LvglCircleSettings : LvglCommonSettings ----@field radius? integer|fun():integer ----@field filled? boolean|fun():boolean ----@field thickness? integer - ---- Arc segment with optional background arc. ----@class LvglArcSettings : LvglCommonSettings ----@field radius? integer|fun():integer ----@field thickness? integer ----@field rounded? boolean Round arc ends ----@field startAngle? integer|fun():integer Degrees (0=right/3 o'clock, clockwise) ----@field endAngle? integer|fun():integer ----@field bgColor? integer|fun():integer Background arc color ----@field bgOpacity? integer|fun():integer Background arc opacity (0-255) ----@field bgStartAngle? integer|fun():integer ----@field bgEndAngle? integer|fun():integer - ---- Display an image from the SD card. ----@class LvglImageSettings : LvglCommonSettings ----@field file? string|fun():string Full path to image file ----@field fill? boolean Scale to fill parent frame (may crop) ----@field active? fun():boolean - ---- Display a QR code. ----@class LvglQrcodeSettings : LvglCommonSettings ----@field data? string URL or content to encode ----@field bgColor? integer Background (light) color - --- ========================================================================== --- lvgl module --- ========================================================================== - ----@class lvgl -lvgl = {} - ---- Remove all LVGL objects, or only children of a specific object. ----@param obj? LvglObject -function lvgl.clear(obj) end - ---- Build a widget tree from a declarative table. ----@param tree table[] ----@return table -function lvgl.build(tree) end - ---- Check if the current script is running in app mode (One-Time script). ----@return boolean -function lvgl.isAppMode() end - ---- Check if the widget is running in full-screen mode. ----@return boolean -function lvgl.isFullScreen() end - ---- Exit full-screen widget mode. -function lvgl.exitFullScreen() end - ---- Get the current widget context table. ----@return table context -function lvgl.getContext() end - ---- Update properties of an existing LVGL object. ----@param obj LvglObject ----@param settings table -function lvgl.set(obj, settings) end - ---- Show an LVGL object. ----@param obj LvglObject -function lvgl.show(obj) end - ---- Hide an LVGL object. ----@param obj LvglObject -function lvgl.hide(obj) end - ---- Enable an LVGL object (restore interactivity). ----@param obj LvglObject -function lvgl.enable(obj) end - ---- Disable an LVGL object (grey out, prevent interaction). ----@param obj LvglObject -function lvgl.disable(obj) end - ---- Close and destroy an LVGL object. ----@param obj LvglObject -function lvgl.close(obj) end - ---- Get the scroll position of an LVGL object. ----@param obj LvglObject ----@return integer x ----@return integer y -function lvgl.getScrollPos(obj) end - --- Widget creation functions - ----@param settings LvglPageSettings ----@return LvglObject -function lvgl.page(settings) end - ----@param parent_or_settings LvglObject|LvglDialogSettings ----@param settings? LvglDialogSettings ----@return LvglObject -function lvgl.dialog(parent_or_settings, settings) end - ----@param settings LvglConfirmSettings ----@return LvglObject -function lvgl.confirm(settings) end - ----@param settings LvglMessageSettings ----@return LvglObject -function lvgl.message(settings) end - ----@param settings LvglMenuSettings ----@return LvglObject -function lvgl.menu(settings) end - ----@param parent_or_settings LvglObject|LvglLabelSettings ----@param settings? LvglLabelSettings ----@return LvglObject -function lvgl.label(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglRectangleSettings ----@param settings? LvglRectangleSettings ----@return LvglObject -function lvgl.rectangle(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglBoxSettings ----@param settings? LvglBoxSettings ----@return LvglObject -function lvgl.box(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglButtonSettings ----@param settings? LvglButtonSettings ----@return LvglObject -function lvgl.button(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglMomentaryButtonSettings ----@param settings? LvglMomentaryButtonSettings ----@return LvglObject -function lvgl.momentaryButton(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglToggleSettings ----@param settings? LvglToggleSettings ----@return LvglObject -function lvgl.toggle(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglTextEditSettings ----@param settings? LvglTextEditSettings ----@return LvglObject -function lvgl.textEdit(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglNumberEditSettings ----@param settings? LvglNumberEditSettings ----@return LvglObject -function lvgl.numberEdit(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglChoiceSettings ----@param settings? LvglChoiceSettings ----@return LvglObject -function lvgl.choice(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglSliderSettings ----@param settings? LvglSliderSettings ----@return LvglObject -function lvgl.slider(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglSliderSettings ----@param settings? LvglSliderSettings ----@return LvglObject -function lvgl.verticalSlider(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglSettingSettings ----@param settings? LvglSettingSettings ----@return LvglObject -function lvgl.setting(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglSourceSettings ----@param settings? LvglSourceSettings ----@return LvglObject -function lvgl.source(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglSwitchSettings ----@param settings? LvglSwitchSettings ----@return LvglObject -function lvgl.switch(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglFileSettings ----@param settings? LvglFileSettings ----@return LvglObject -function lvgl.file(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglFontSettings ----@param settings? LvglFontSettings ----@return LvglObject -function lvgl.font(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglAlignSettings ----@param settings? LvglAlignSettings ----@return LvglObject -function lvgl.align(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglColorSettings ----@param settings? LvglColorSettings ----@return LvglObject -function lvgl.color(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglTimerSettings ----@param settings? LvglTimerSettings ----@return LvglObject -function lvgl.timer(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglHlineSettings ----@param settings? LvglHlineSettings ----@return LvglObject -function lvgl.hline(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglVlineSettings ----@param settings? LvglVlineSettings ----@return LvglObject -function lvgl.vline(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglLineSettings ----@param settings? LvglLineSettings ----@return LvglObject -function lvgl.line(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglTriangleSettings ----@param settings? LvglTriangleSettings ----@return LvglObject -function lvgl.triangle(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglCircleSettings ----@param settings? LvglCircleSettings ----@return LvglObject -function lvgl.circle(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglArcSettings ----@param settings? LvglArcSettings ----@return LvglObject -function lvgl.arc(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglImageSettings ----@param settings? LvglImageSettings ----@return LvglObject -function lvgl.image(parent_or_settings, settings) end - ----@param parent_or_settings LvglObject|LvglQrcodeSettings ----@param settings? LvglQrcodeSettings ----@return LvglObject -function lvgl.qrcode(parent_or_settings, settings) end - --- ========================================================================== --- LVGL constants --- ========================================================================== - --- Layout flow ----@type integer -lvgl.FLOW_ROW = 0 ----@type integer -lvgl.FLOW_COLUMN = 0 - --- Padding presets ----@type integer -lvgl.PAD_TINY = 0 ----@type integer -lvgl.PAD_SMALL = 0 ----@type integer -lvgl.PAD_MEDIUM = 0 ----@type integer -lvgl.PAD_LARGE = 0 ----@type integer -lvgl.PAD_OUTLINE = 0 ----@type integer -lvgl.PAD_BORDER = 0 - --- Source filter bitmask constants ----@type integer -lvgl.SRC_ALL = 0 ----@type integer -lvgl.SRC_INPUT = 0 ----@type integer -lvgl.SRC_LUA = 0 ----@type integer -lvgl.SRC_STICK = 0 ----@type integer -lvgl.SRC_POT = 0 ----@type integer -lvgl.SRC_OTHER = 0 ----@type integer -lvgl.SRC_HELI = 0 ----@type integer -lvgl.SRC_TRIM = 0 ----@type integer -lvgl.SRC_SWITCH = 0 ----@type integer -lvgl.SRC_LOGICAL_SWITCH = 0 ----@type integer -lvgl.SRC_TRAINER = 0 ----@type integer -lvgl.SRC_CHANNEL = 0 ----@type integer -lvgl.SRC_GVAR = 0 ----@type integer -lvgl.SRC_TELEM = 0 ----@type integer -lvgl.SRC_CLEAR = 0 ----@type integer -lvgl.SRC_INVERT = 0 - --- Switch filter bitmask constants ----@type integer -lvgl.SW_ALL = 0 ----@type integer -lvgl.SW_SWITCH = 0 ----@type integer -lvgl.SW_TRIM = 0 ----@type integer -lvgl.SW_LOGICAL_SWITCH = 0 ----@type integer -lvgl.SW_TELEM = 0 ----@type integer -lvgl.SW_OTHER = 0 ----@type integer -lvgl.SW_CLEAR = 0 - --- Scroll direction constants ----@type integer -lvgl.SCROLL_OFF = 0 ----@type integer -lvgl.SCROLL_HOR = 0 ----@type integer -lvgl.SCROLL_VER = 0 ----@type integer -lvgl.SCROLL_ALL = 0 - --- Layout helper constants ----@type integer -lvgl.PERCENT_SIZE = 0 ----@type integer -lvgl.PAGE_BODY_HEIGHT = 0 ----@type integer -lvgl.UI_ELEMENT_HEIGHT = 0 ----@type number -lvgl.LCD_SCALE = 0.0 - --- Widget type constants (for use in build() tables) ----@type integer -lvgl.LABEL = 0 ----@type integer -lvgl.RECTANGLE = 0 ----@type integer -lvgl.CIRCLE = 0 ----@type integer -lvgl.ARC = 0 ----@type integer -lvgl.HLINE = 0 ----@type integer -lvgl.VLINE = 0 ----@type integer -lvgl.LINE = 0 ----@type integer -lvgl.TRIANGLE = 0 ----@type integer -lvgl.IMAGE = 0 ----@type integer -lvgl.QRCODE = 0 ----@type integer -lvgl.BOX = 0 ----@type integer -lvgl.BUTTON = 0 ----@type integer -lvgl.MOMENTARY_BUTTON = 0 ----@type integer -lvgl.TOGGLE = 0 ----@type integer -lvgl.TEXT_EDIT = 0 ----@type integer -lvgl.NUMBER_EDIT = 0 ----@type integer Typo in C++ source, kept for compatibility -lvgl.CHOIDE = 0 ----@type integer -lvgl.CHOICE = 0 ----@type integer -lvgl.SLIDER = 0 ----@type integer -lvgl.VERTICAL_SLIDER = 0 ----@type integer -lvgl.PAGE = 0 ----@type integer -lvgl.FONT = 0 ----@type integer -lvgl.ALIGN = 0 ----@type integer -lvgl.COLOR = 0 ----@type integer -lvgl.TIMER = 0 ----@type integer -lvgl.SWITCH = 0 ----@type integer -lvgl.SOURCE = 0 ----@type integer -lvgl.FILE = 0 ----@type integer -lvgl.SETTING = 0 diff --git a/edgetx-lua-stdlib/model.d.lua b/edgetx-lua-stdlib/model.d.lua deleted file mode 100644 index e78ec8b..0000000 --- a/edgetx-lua-stdlib/model.d.lua +++ /dev/null @@ -1,352 +0,0 @@ ----@meta - --- EdgeTX model module --- Source: edgetx/radio/src/lua/api_model.cpp - ----@class model -model = {} - ---- Get current model information. ---- @since 2.0.6 ----@return {name: string, extendedLimits: boolean, jitterFilter: integer, bitmap: string, labels: string, filename: string} -function model.getInfo() end - ---- Set current model information. Missing fields remain unchanged. ---- @since 2.0.6 ----@param info {name?: string, extendedLimits?: boolean, jitterFilter?: integer, bitmap?: string} -function model.setInfo(info) end - ---- Get RF module parameters. Returns nil if index is invalid. ---- @since 2.2.0 ----@param index integer Module slot (0=internal, 1=external) ----@return {subType: integer, modelId: integer, firstChannel: integer, channelsCount: integer, Type: integer, protocol?: integer, subProtocol?: integer, channelsOrder?: integer}|nil module -function model.getModule(index) end - ---- Set RF module parameters. Missing fields remain unchanged. ---- @since 2.2.0 ----@param index integer Module slot (0=internal, 1=external) ----@param config {Type?: integer, subType?: integer, modelId?: integer, firstChannel?: integer, channelsCount?: integer, protocol?: integer, subProtocol?: integer} -function model.setModule(index, config) end - ---- Get model timer parameters. ---- @since 2.0.0 ----@param index integer Timer index (0-based) ----@return {mode: integer, start: integer, value: integer, countdownBeep: integer, minuteBeep: boolean, persistent: integer, name: string, showElapsed: boolean, switch: integer, countdownStart: integer, extraHaptic: integer}|nil timer -function model.getTimer(index) end - ---- Set model timer parameters. Missing fields remain unchanged. ---- @since 2.0.0 ----@param index integer Timer index (0-based) ----@param config {mode?: integer, start?: integer, value?: integer, countdownBeep?: integer, minuteBeep?: boolean, persistent?: integer, name?: string, showElapsed?: boolean, switch?: integer, countdownStart?: integer, extraHaptic?: integer} -function model.setTimer(index, config) end - ---- Reset model timer to its startup value. ---- @since 2.0.0 ----@param index integer Timer index (0-based) -function model.resetTimer(index) end - ---- Clear all flight modes. -function model.deleteFlightModes() end - ---- Get flight mode data. ---- @since 2.1.7 ----@param index integer Flight mode index (0-based) ----@return {name: string, switch: integer, fadeIn: integer, fadeOut: integer, trimsValues: table, trimsModes: table} flightMode -function model.getFlightMode(index) end - ---- Set flight mode parameters. Missing fields remain unchanged. ---- @since 2.1.7 ----@param index integer Flight mode index (0-based) ----@param config {name?: string, switch?: integer, fadeIn?: integer, fadeOut?: integer, trimsValues?: table, trimsModes?: table} ----@return integer result 0=success, 2=invalid index -function model.setFlightMode(index, config) end - ---- Return the number of lines for the given input. ---- @since 2.0.0 ----@param input integer Input index (0-based) ----@return integer count -function model.getInputsCount(input) end - ---- Get input line configuration. ---- @since 2.0.0 ----@param input integer Input index (0-based) ----@param line integer Line number within input (0-based) ----@return {name: string, inputName: string, source: integer, scale: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, trimSource: integer, side: integer, flightModes: integer} inputConfig -function model.getInput(input, line) end - ---- Insert an input line at the specified position. ---- @since 2.0.0 ----@param input integer Input index (0-based) ----@param line integer Line number to insert at (0-based) ----@param config {name?: string, inputName?: string, source?: integer, scale?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, trimSource?: integer, side?: integer, flightModes?: integer} -function model.insertInput(input, line, config) end - ---- Delete a line from the specified input. ---- @since 2.0.0 ----@param input integer Input index (0-based) ----@param line integer Line number (0-based) -function model.deleteInput(input, line) end - ---- Delete all input lines. -function model.deleteInputs() end - ---- Set all inputs to defaults (one stick per input). -function model.defaultInputs() end - ---- Get the number of mixer lines for the given channel. ---- @since 2.0.0 ----@param channel integer Channel index (0-based) ----@return integer count -function model.getMixesCount(channel) end - ---- Get mixer line configuration. ---- @since 2.0.0 ----@param channel integer Channel index (0-based) ----@param line integer Line number within channel (0-based) ----@return {name: string, source: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, multiplex: integer, flightModes: integer, carryTrim: boolean, mixWarn: integer, delayPrec: integer, delayUp: integer, delayDown: integer, speedPrec: integer, speedUp: integer, speedDown: integer} mix -function model.getMix(channel, line) end - ---- Insert a mixer line at the specified position. ---- @since 2.0.0 ----@param channel integer Channel index (0-based) ----@param line integer Line number to insert at (0-based) ----@param config {name?: string, source?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, multiplex?: integer, flightModes?: integer, carryTrim?: boolean, mixWarn?: integer, delayPrec?: integer, delayUp?: integer, delayDown?: integer, speedPrec?: integer, speedUp?: integer, speedDown?: integer} -function model.insertMix(channel, line, config) end - ---- Delete a mixer line from the specified channel. ---- @since 2.0.0 ----@param channel integer Channel index (0-based) ----@param line integer Line number (0-based) -function model.deleteMix(channel, line) end - ---- Remove all mixer lines. -function model.deleteMixes() end - ---- Get logical switch parameters. ---- @since 2.0.0 ----@param index integer Logical switch index (0-based) ----@return {func: integer, v1: integer, v2: integer, v3: integer, ["and"]: integer, delay: integer, duration: integer, state: boolean, persistent: boolean} logicalSwitch -function model.getLogicalSwitch(index) end - ---- Set logical switch parameters. Missing fields remain unchanged. ---- @since 2.0.0 ----@param index integer Logical switch index (0-based) ----@param config {func?: integer, v1?: integer, v2?: integer, v3?: integer, ["and"]?: integer, delay?: integer, duration?: integer, state?: boolean, persistent?: boolean} -function model.setLogicalSwitch(index, config) end - ---- Get custom (special) function parameters. ---- @since 2.0.0 ----@param index integer Function index (0-based) ----@return {switch: integer, func: integer, name?: string, value?: integer, mode?: integer, param?: integer, active: integer, repetition: integer} customFunction -function model.getCustomFunction(index) end - ---- Set custom (special) function parameters. Missing fields remain unchanged. ---- @since 2.0.0 ----@param index integer Function index (0-based) ----@param config {switch?: integer, func?: integer, name?: string, value?: integer, mode?: integer, param?: integer, active?: integer, repetition?: integer} -function model.setCustomFunction(index, config) end - ---- Get curve parameters. The `y` table contains point values (1-based). ---- The `x` table is only present for custom curve types. ---- @since 2.0.12 ----@param index integer Curve index (0-based) ----@return {name: string, type: integer, smooth: boolean, points: integer, y: table, x?: table} curve -function model.getCurve(index) end - ---- Set curve parameters. ---- @since 2.0.12 ----@param index integer Curve index (0-based) ----@param config {name?: string, type?: integer, smooth?: boolean, points?: integer, y?: table, x?: table} ----@return integer result 0=success, 1=wrong number of points, 2=invalid curve number -function model.setCurve(index, config) end - ---- Get output (servo) channel parameters. Values are in 0.1% units. ---- @since 2.0.0 ----@param index integer Output channel index (0-based) ----@return {name: string, min: integer, max: integer, offset: integer, ppmCenter: integer, symetrical: integer, revert: integer, curve?: integer} output -function model.getOutput(index) end - ---- Set output (servo) channel parameters. Missing fields remain unchanged. ---- @since 2.0.0 ----@param index integer Output channel index (0-based) ----@param config {name?: string, min?: integer, max?: integer, offset?: integer, ppmCenter?: integer, symetrical?: integer, revert?: integer, curve?: integer} -function model.setOutput(index, config) end - ---- Get the current value of a global variable. ---- @since 2.1.0 ----@param index integer GVar index (0-based) ----@param flightMode integer Flight mode index (0-based) ----@return integer value -function model.getGlobalVariable(index, flightMode) end - ---- Set the value of a global variable. ---- @since 2.1.0 ----@param index integer GVar index (0-based) ----@param flightMode integer Flight mode index (0-based) ----@param value integer -function model.setGlobalVariable(index, flightMode, value) end - ---- Get global variable details (name, range, unit, etc.). ----@param index integer GVar index (0-based) ----@return {name: string, min: integer, max: integer, prec: integer, unit: integer, popup: boolean}|nil details -function model.getGlobalVariableDetails(index) end - ---- Set global variable details. Missing fields remain unchanged. ----@param index integer GVar index (0-based) ----@param details {name?: string, min?: integer, max?: integer, prec?: integer, unit?: integer, popup?: boolean} -function model.setGlobalVariableDetails(index, details) end - ---- Get telemetry sensor parameters. The `id` and `instance` fields are ---- only present for custom sensors; `formula` is only present for calculated sensors. ---- @since 2.2.0 ----@param index integer Sensor index (0-based) ----@return {type: integer, name: string, unit: integer, prec: integer, id?: integer, instance?: integer, formula?: integer} sensor -function model.getSensor(index) end - ---- Reset telemetry sensor to default value. ----@param index integer Sensor index (0-based) -function model.resetSensor(index) end - ---- Get heli swash ring parameters. Only available on builds with HELI support. ----@return {type: integer, value: integer, collectiveSource: integer, aileronSource: integer, elevatorSource: integer, collectiveWeight: integer, aileronWeight: integer, elevatorWeight: integer} swashRing -function model.getSwashRing() end - ---- Set heli swash ring parameters. Missing fields remain unchanged. ---- Only available on builds with HELI support. ----@param config {type?: integer, value?: integer, collectiveSource?: integer, aileronSource?: integer, elevatorSource?: integer, collectiveWeight?: integer, aileronWeight?: integer, elevatorWeight?: integer} -function model.setSwashRing(config) end - --- ========================================================================== --- Global model-related functions --- ========================================================================== - ---- Return trainer connection status. ----@return integer status -function getTrainerStatus() end - --- ========================================================================== --- Input / source type constants --- ========================================================================== - ----@type integer -VALUE = 0 ----@type integer -SOURCE = 0 ----@type integer -REPLACE = 0 ----@type integer -MIXSRC_MIN = 0 ----@type integer -MIXSRC_MAX = 0 ----@type integer -MIXSRC_FIRST_INPUT = 0 ----@type integer -MIXSRC_CH1 = 0 ----@type integer -SWSRC_LAST = 0 ----@type integer -SWITCH_COUNT = 0 ----@type integer -MAX_SENSORS = 0 ----@type integer -MAX_OUTPUT_CHANNELS = 0 ----@type integer -LIMIT_EXT_PERCENT = 0 ----@type integer -LIMIT_STD_PERCENT = 0 - --- ========================================================================== --- Logical switch function constants --- ========================================================================== - ----@type integer -LS_FUNC_NONE = 0 ----@type integer -LS_FUNC_VEQUAL = 0 ----@type integer -LS_FUNC_VALMOSTEQUAL = 0 ----@type integer -LS_FUNC_VPOS = 0 ----@type integer -LS_FUNC_VNEG = 0 ----@type integer -LS_FUNC_APOS = 0 ----@type integer -LS_FUNC_ANEG = 0 ----@type integer -LS_FUNC_AND = 0 ----@type integer -LS_FUNC_OR = 0 ----@type integer -LS_FUNC_XOR = 0 ----@type integer -LS_FUNC_EDGE = 0 ----@type integer -LS_FUNC_EQUAL = 0 ----@type integer -LS_FUNC_GREATER = 0 ----@type integer -LS_FUNC_LESS = 0 ----@type integer -LS_FUNC_DIFFEGREATER = 0 ----@type integer -LS_FUNC_ADIFFEGREATER = 0 ----@type integer -LS_FUNC_TIMER = 0 ----@type integer -LS_FUNC_STICKY = 0 - --- ========================================================================== --- Custom function constants --- ========================================================================== - ----@type integer -FUNC_OVERRIDE_CHANNEL = 0 ----@type integer -FUNC_TRAINER = 0 ----@type integer -FUNC_INSTANT_TRIM = 0 ----@type integer -FUNC_RESET = 0 ----@type integer -FUNC_SET_TIMER = 0 ----@type integer -FUNC_ADJUST_GVAR = 0 ----@type integer -FUNC_VOLUME = 0 ----@type integer -FUNC_SET_FAILSAFE = 0 ----@type integer -FUNC_RANGECHECK = 0 ----@type integer -FUNC_BIND = 0 ----@type integer -FUNC_PLAY_SOUND = 0 ----@type integer -FUNC_PLAY_TRACK = 0 ----@type integer -FUNC_PLAY_VALUE = 0 ----@type integer -FUNC_PLAY_SCRIPT = 0 ----@type integer -FUNC_BACKGND_MUSIC = 0 ----@type integer -FUNC_BACKGND_MUSIC_PAUSE = 0 ----@type integer -FUNC_VARIO = 0 ----@type integer -FUNC_HAPTIC = 0 ----@type integer -FUNC_LOGS = 0 ----@type integer -FUNC_BACKLIGHT = 0 ----@type integer -FUNC_SCREENSHOT = 0 ----@type integer -FUNC_RACING_MODE = 0 ----@type integer -FUNC_PUSH_CUST_SWITCH = 0 ----@type integer -FUNC_SET_SCREEN = 0 ----@type integer -FUNC_DISABLE_TOUCH = 0 diff --git a/edgetx-lua-stdlib/runtime.d.lua b/edgetx-lua-stdlib/runtime.d.lua deleted file mode 100644 index af45be6..0000000 --- a/edgetx-lua-stdlib/runtime.d.lua +++ /dev/null @@ -1,22 +0,0 @@ ----@meta - --- EdgeTX Lua runtime introspection functions --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Load and compile a Lua script from the SD card. ---- Returns the compiled chunk or nil + error message. ---- @since 2.2.0 ----@param path? string Script file path ----@param mode? string Compilation mode ----@param env? table Custom environment table ----@return function|nil chunk ----@return string|nil errmsg -function loadScript(path, mode, env) end - ---- Return Lua memory usage as a percentage (0-100). ----@return integer percent -function getUsage() end - ---- Return available Lua memory in bytes. ----@return integer bytes -function getAvailableMemory() end diff --git a/edgetx-lua-stdlib/serial.d.lua b/edgetx-lua-stdlib/serial.d.lua deleted file mode 100644 index 96d4564..0000000 --- a/edgetx-lua-stdlib/serial.d.lua +++ /dev/null @@ -1,28 +0,0 @@ ----@meta - --- EdgeTX serial port communication functions --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Set the serial port baud rate for Lua serial communication. ----@param baudrate integer Baud rate (e.g. 115200) -function setSerialBaudrate(baudrate) end - ---- Write a string to the serial port. ----@param data string Bytes to send -function serialWrite(data) end - ---- Read bytes from the serial port. Returns an empty string if no data available. ----@param num? integer Maximum number of bytes to read (0 = all available) ----@return string data -function serialRead(num) end - ---- Get external serial port power state. ----@param port_nr integer Port number ----@return boolean|nil power true/false for power state, nil if unavailable -function serialGetPower(port_nr) end - ---- Set external serial port power state. ----@param port_nr integer Port number ----@param power integer Power value (0=off, 1=on) ----@return boolean success -function serialSetPower(port_nr, power) end diff --git a/edgetx-lua-stdlib/telemetry.d.lua b/edgetx-lua-stdlib/telemetry.d.lua deleted file mode 100644 index 8e6050f..0000000 --- a/edgetx-lua-stdlib/telemetry.d.lua +++ /dev/null @@ -1,180 +0,0 @@ ----@meta - --- EdgeTX telemetry protocol functions and sensor unit constants --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Push telemetry data via ACCESS protocol (PXX2). When called without ---- parameters, returns the output buffer availability. ----@overload fun(): boolean ----@param module integer Module number (-1 for default) ----@param rxUid integer Receiver UID ----@param sensorId integer Physical sensor ID ----@param frameId integer Frame type ----@param dataId integer Data ID ----@param value integer Data value ----@return boolean success -function accessTelemetryPush(module, rxUid, sensorId, frameId, dataId, value) end - ---- Pop a received S.PORT telemetry frame. Returns nil if queue is empty. ---- @since 2.0.0 ----@return integer|nil physicalId ----@return integer|nil primId ----@return integer|nil dataId ----@return integer|nil value -function sportTelemetryPop() end - ---- Push an S.PORT telemetry frame. When called without parameters, ---- returns buffer availability. Returns nil if protocol is wrong. ---- @since 2.0.0 ----@overload fun(): boolean ----@param sensor integer Sensor physical ID ----@param frame integer Frame type ----@param dataId integer Data ID ----@param value integer Data value ----@return nil|boolean success nil if wrong protocol, boolean otherwise -function sportTelemetryPush(sensor, frame, dataId, value) end - ---- Set a telemetry sensor value. Automatically creates the sensor if needed. ---- @since 2.2.0 ----@param id integer Sensor ID ----@param subId integer Sub-ID (0-7) ----@param instance integer Instance ----@param value integer Sensor value ----@param unit? integer Unit constant (UNIT_*, default 0) ----@param precision? integer Decimal precision (default 0) ----@param name? string Sensor name (up to 4 chars, auto-generated from ID if omitted) ----@return boolean success -function setTelemetryValue(id, subId, instance, value, unit, precision, name) end - ---- Pop a received Crossfire telemetry packet. Returns nil if queue is empty. ---- @since 2.2.0 ----@return integer|nil command ----@return integer[]|nil data Array of payload bytes -function crossfireTelemetryPop() end - ---- Push a Crossfire telemetry packet. When called without parameters, ---- returns buffer availability. Returns nil if protocol is wrong. ---- @since 2.2.0 ----@overload fun(): boolean ----@param command integer CRSF command byte ----@param data integer[] Array of payload bytes ----@return nil|boolean success nil if wrong protocol, boolean otherwise -function crossfireTelemetryPush(command, data) end - ---- Pop a received Ghost telemetry packet. Returns nil if queue is empty. ---- @since 2.7.0 ----@return integer|nil type ----@return integer[]|nil data Array of payload bytes -function ghostTelemetryPop() end - ---- Push a Ghost telemetry packet. When called without parameters, ---- returns buffer availability. Returns nil if protocol is wrong. ---- @since 2.7.0 ----@overload fun(): boolean ----@param type integer Ghost frame type ----@param data integer[] Array of payload bytes (max 10) ----@return nil|boolean success nil if wrong protocol, boolean otherwise -function ghostTelemetryPush(type, data) end - ---- Read or write the Multi module shared buffer. When called with two ---- arguments, writes `value` at `index`. Always returns the current value. ----@param index integer Buffer index (0-based) ----@param value? integer Value to write (omit to just read; internal sentinel is 0x100) ----@return integer value Current buffer value at index -function multiBuffer(index, value) end - ---- Return RSSI value and alarm thresholds. ---- @since 2.0.0 ----@return integer rssi Current RSSI ----@return integer alarm_low Low alarm threshold ----@return integer alarm_crit Critical alarm threshold -function getRSSI() end - ---- Return RSSI Alarm Status (RAS) or nil if not available. ----@return integer|nil value -function getRAS() end - ---- Return built-in GPS data or nil if no GPS present. ----@return table|nil gpsData -function getTxGPS() end - --- ========================================================================== --- Unit constants --- ========================================================================== - ----@type integer -UNIT_RAW = 0 ----@type integer -UNIT_VOLTS = 0 ----@type integer -UNIT_AMPS = 0 ----@type integer -UNIT_MILLIAMPS = 0 ----@type integer -UNIT_KTS = 0 ----@type integer -UNIT_METERS_PER_SECOND = 0 ----@type integer -UNIT_FEET_PER_SECOND = 0 ----@type integer -UNIT_KMH = 0 ----@type integer -UNIT_MPH = 0 ----@type integer -UNIT_METERS = 0 ----@type integer -UNIT_KM = 0 ----@type integer -UNIT_FEET = 0 ----@type integer -UNIT_CELSIUS = 0 ----@type integer -UNIT_FAHRENHEIT = 0 ----@type integer -UNIT_PERCENT = 0 ----@type integer -UNIT_MAH = 0 ----@type integer -UNIT_WATTS = 0 ----@type integer -UNIT_MILLIWATTS = 0 ----@type integer -UNIT_DB = 0 ----@type integer -UNIT_RPMS = 0 ----@type integer -UNIT_G = 0 ----@type integer -UNIT_DEGREE = 0 ----@type integer -UNIT_RADIANS = 0 ----@type integer -UNIT_MILLILITERS = 0 ----@type integer -UNIT_FLOZ = 0 ----@type integer -UNIT_MILLILITERS_PER_MINUTE = 0 ----@type integer -UNIT_HERTZ = 0 ----@type integer -UNIT_MS = 0 ----@type integer -UNIT_US = 0 ----@type integer -UNIT_DBM = 0 ----@type integer -UNIT_HOURS = 0 ----@type integer -UNIT_MINUTES = 0 ----@type integer -UNIT_SECONDS = 0 ----@type integer -UNIT_CELLS = 0 ----@type integer -UNIT_DATETIME = 0 ----@type integer -UNIT_GPS = 0 ----@type integer -UNIT_BITFIELD = 0 ----@type integer -UNIT_TEXT = 0 diff --git a/edgetx-lua-stdlib/time.d.lua b/edgetx-lua-stdlib/time.d.lua deleted file mode 100644 index 1b94d06..0000000 --- a/edgetx-lua-stdlib/time.d.lua +++ /dev/null @@ -1,19 +0,0 @@ ----@meta - --- EdgeTX time and clock functions --- Source: edgetx/radio/src/lua/api_general.cpp - ---- Return the time since the radio was started in multiples of 10ms. ---- Uses a 32-bit counter (enough for 497 days). ---- @since 2.0.0 ----@return integer timestamp 10ms tick count -function getTime() end - ---- Return current system date and time from the RTC. ---- @since 2.0.0 ----@return {year: integer, mon: integer, day: integer, hour: integer, min: integer, sec: integer, hour12: integer, suffix: string} -function getDateTime() end - ---- Return current RTC time as a Unix timestamp (seconds since epoch). ----@return integer seconds Unix timestamp from RTC -function getRtcTime() end diff --git a/edgetx-lua-stdlib/widget.d.lua b/edgetx-lua-stdlib/widget.d.lua deleted file mode 100644 index eec8db7..0000000 --- a/edgetx-lua-stdlib/widget.d.lua +++ /dev/null @@ -1,43 +0,0 @@ ----@meta - --- EdgeTX widget shared memory and option type constants --- Source: edgetx/radio/src/lua/api_general.cpp - --- ========================================================================== --- Shared memory (color LCD only) --- ========================================================================== - ---- Set a shared memory variable (for inter-widget communication). Color LCD only. ----@param index integer Variable index ----@param value integer -function setShmVar(index, value) end - ---- Get a shared memory variable value. Color LCD only. ----@param index integer Variable index ----@return integer|nil value nil if index is invalid -function getShmVar(index) end - --- ========================================================================== --- Widget option type constants --- ========================================================================== - ----@type integer -COLOR = 0 ----@type integer -BOOL = 0 ----@type integer -STRING = 0 ----@type integer -TIMER = 0 ----@type integer -TEXT_SIZE = 0 ----@type integer -ALIGNMENT = 0 ----@type integer -SWITCH = 0 ----@type integer -SLIDER = 0 ----@type integer -CHOICE = 0 ----@type integer -FILE = 0 From a8a7b8934d1ede60b6c6b05ede8a19bf120784f0 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 16 Mar 2026 15:22:12 +0200 Subject: [PATCH 085/218] add edgetx-lua-stdlib as submodule --- .gitmodules | 4 ++++ edgetx-lua-stdlib | 1 + 2 files changed, 5 insertions(+) create mode 100644 .gitmodules create mode 160000 edgetx-lua-stdlib diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9d1e9d4 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "edgetx-lua-stdlib"] + path = edgetx-lua-stdlib + url = https://github.com/jurgelenas/edgetx-lua-stdlib.git + branch = edgetx-2.12 diff --git a/edgetx-lua-stdlib b/edgetx-lua-stdlib new file mode 160000 index 0000000..9ecb146 --- /dev/null +++ b/edgetx-lua-stdlib @@ -0,0 +1 @@ +Subproject commit 9ecb146c1f9820e0a057d8bc55f56206d14cee5f From e183664beba014ce00bfe56d03deeb8ef223f3c0 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 16 Mar 2026 15:24:11 +0200 Subject: [PATCH 086/218] include submodules in github action checkout --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e17fe93..5833982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + with: + submodules: true - name: Cache cargo uses: actions/cache@v5 From 37abbfc7a057084b26638376e3f5dd361b7a773d Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 29 Mar 2026 09:14:49 +0300 Subject: [PATCH 087/218] update edgetx versions information --- README.md | 4 ++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 12 +++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8a678fe..dd14930 100644 --- a/README.md +++ b/README.md @@ -123,5 +123,5 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | Radio type | Firmware | ExpressLRS | |------------|----------|------------| -| Black & white LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | -| Color LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | +| Black & white LCD | EdgeTX 2.12+ or 3.0+ | v3.0+ | +| Color LCD | EdgeTX 2.12+ or 3.0+ | v3.0+ | diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index c6549f4..ffbd96b 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -338,13 +338,7 @@ local function checkEdgeTxVersion() if maj >= 3 then return true - elseif maj == 2 and minor >= 13 then - return true - elseif maj == 2 and minor == 12 then - local rc = string.match(ver, "%-rc(%d+)") - if rc then - return tonumber(rc) >= 4 - end + elseif maj == 2 and minor >= 12 then return true end @@ -371,7 +365,7 @@ local function showVersionRequired() flexPad = lvgl.PAD_SMALL, children = { { type = "label", text = "Requires EdgeTX:" }, - { type = "label", text = "- 2.12-rc4 or later" }, + { type = "label", text = "- 2.12 or later" }, { type = "label", text = "- 3.0 or later" }, }, }, @@ -399,7 +393,7 @@ local function showLvglRequired() lcd.clear() lcd.drawText(5, 10, "LVGL support required", BOLD) lcd.drawText(5, 20, "Color LCD radio with", 0) - lcd.drawText(5, 30, "EdgeTX 2.12-rc4+ or 3.0+", 0) + lcd.drawText(5, 30, "EdgeTX 2.12+ or 3.0+", 0) lcd.drawText(5, 40, "needed", 0) end From 43ec52a6ce872f7fa180018757def295307d0e50 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 1 Apr 2026 10:35:53 +0300 Subject: [PATCH 088/218] remove dead code in lvgl --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 27 ------------------------ 1 file changed, 27 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index ffbd96b..26e3f14 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -681,33 +681,6 @@ end -- Field value increment -- ============================================================================ -function UI.incrField(field, step) - local min, max = 0, 0 - if field.type <= Protocol.CRSF.FLOAT then - min = field.min or 0 - max = field.max or 0 - step = (field.step or 1) * step - elseif field.type == Protocol.CRSF.TEXT_SELECTION then - min = 0 - max = #field.values - 1 - end - - local newval = field.value - repeat - newval = newval + step - if newval < min then - newval = min - elseif newval > max then - newval = max - end - - if field.values == nil or #field.values[newval + 1] ~= 0 then - field.value = newval - return - end - until newval == min or newval == max -end - function UI.isBooleanField(field) if not field.values or #field.values ~= 2 then return false From 4f3034379a2efb438a4346464ee9d0b9ac561276 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 1 Apr 2026 10:38:04 +0300 Subject: [PATCH 089/218] Add editor config --- .editorconfig | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6a2e7d6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.lua] +indent_style = space +indent_size = 2 +max_line_length = 120 + +[Makefile] +indent_style = tab + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false From ab516d2e2d118b3a2ac03b74a2a7915e44475c01 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 2 Apr 2026 10:27:03 +0300 Subject: [PATCH 090/218] feat: add per-channel output mapping config to CRSFSimulator Changing Output Ch now loads distinct Input Ch, Output Mode, and Invert values from a per-channel config table, mimicking RXParameters.cpp firmware behavior. Editing siblings saves back to the current channel. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index dc9b120..3129973 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -94,6 +94,20 @@ local rateConfigs = { [3] = { hz = 500, interval = 2000, defaultTlm = 2 }, -- TLM_RATIO_1_128 } +-- ============================================================================ +-- Per-channel PWM output config (mirrors rx_config_pwm_t in firmware) +-- Maps output channel index (1-4) to its Input Ch, Output Mode, and Invert. +-- When Output Ch changes, siblings are loaded from this table. +-- When siblings are edited, their values are saved back here. +-- ============================================================================ + +local pwmChannelConfig = { + [1] = { inputChannel = 1, mode = 0, inverted = 0 }, + [2] = { inputChannel = 2, mode = 1, inverted = 0 }, + [3] = { inputChannel = 3, mode = 2, inverted = 1 }, + [4] = { inputChannel = 4, mode = 0, inverted = 0 }, +} + -- ============================================================================ -- FIFO Packet Queue -- ============================================================================ @@ -1154,6 +1168,46 @@ local function mockPush(command, data) else param.value = writeValue end + -- Output Mapping per-channel config: mimic firmware behavior + -- where changing Output Ch loads sibling values from per-channel config, + -- and editing siblings saves back to the current channel's config. + if device.id == CRSF.ADDRESS_CRSF_RECEIVER then + if param.id == 9 then + -- Output Ch changed: load config for the selected channel + local cfg = pwmChannelConfig[param.value] + if cfg then + local inputChParam = findParam(device, 10) + local outputModeParam = findParam(device, 11) + local invertParam = findParam(device, 12) + if inputChParam then + inputChParam.value = cfg.inputChannel + end + if outputModeParam then + outputModeParam.value = cfg.mode + end + if invertParam then + invertParam.value = cfg.inverted + end + end + elseif param.id == 10 or param.id == 11 or param.id == 12 then + -- Sibling edited: save back to current output channel's config + local outputChParam = findParam(device, 9) + local ch = outputChParam and outputChParam.value or 1 + local cfg = pwmChannelConfig[ch] + if cfg then + if param.id == 10 then + cfg.inputChannel = param.value + end + if param.id == 11 then + cfg.mode = param.value + end + if param.id == 12 then + cfg.inverted = param.value + end + end + end + end + -- Defer folder name and bandwidth updates to the next poll cycle. -- Real firmware runs updateFolderNames() in the event loop, not -- in the PARAMETER_WRITE handler. No auto-send of parent folder From f4aaf9d334e08391eaaf4fd28c466b9b87167c21 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 2 Apr 2026 10:27:17 +0300 Subject: [PATCH 091/218] add TODO.md to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d979cc3..addd2f5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ color/WIDGETS/ELRSVTXAdmin/presets.txt bin/ .claude/plans/ +TODO.md From 535cdb6000439421fa96616436f81e1ab6c12e83 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 14 Apr 2026 09:32:15 +0300 Subject: [PATCH 092/218] Add package metadata to edgetx.yml manifest Add name, authors, urls, screenshots, and keywords fields. Rename the existing `name` field to `id` to match the manifest schema. --- edgetx.yml | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/edgetx.yml b/edgetx.yml index 005ee9f..55991f9 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -1,6 +1,25 @@ package: - name: expresslrs - description: ExpressLRS Lua scripts and widgets for EdgeTX + id: expresslrs + name: ExpressLRS + description: ExpressLRS Lua scripts and widgets + authors: + - name: ExpressLRS Contributors + email: info@expresslrs.org + urls: + - name: Homepage + url: "https://www.expresslrs.org" + - name: Repository + url: "https://github.com/ExpressLRS/ExpressLRS-Lua-Scripts" + screenshots: + - screenshots/tool_main.png + - screenshots/tool_main_bw.png + - screenshots/widget_telemetry_fullscren.png + - screenshots/widget_vtxadmin_fullscreen.png + - screenshots/widgets.png + keywords: + - expresslrs + - elrs + - telemetry license: GPL-3.0 source_dir: src min_edgetx_version: "2.12.0" From 67c88352947ce368e58089ea35e6af33a95d9e3d Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 19 Apr 2026 14:17:10 +0300 Subject: [PATCH 093/218] update edgetx package format --- edgetx.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/edgetx.yml b/edgetx.yml index 55991f9..ac0e181 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -1,5 +1,5 @@ package: - id: expresslrs + id: github.com/ExpressLRS/Lua-Scripts name: ExpressLRS description: ExpressLRS Lua scripts and widgets authors: @@ -20,7 +20,7 @@ package: - expresslrs - elrs - telemetry - license: GPL-3.0 + license: GPL-3.0-only source_dir: src min_edgetx_version: "2.12.0" From 5f9278970d07a861b37567bc894fc7e87386118c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 13 May 2026 11:31:26 +0300 Subject: [PATCH 094/218] raise minimum EdgeTX to 2.11.6 / 2.12.1 Enforce the per-branch minimum at runtime in both LVGL and BW UIs and update the manifest pre-install gate. Drop unreachable lvgl-nil fallback code in ui/lvgl.lua. --- README.md | 4 +-- edgetx.yml | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 6 ++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 42 ++++++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 31 +++++------------ 5 files changed, 57 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dd14930..b73eaa3 100644 --- a/README.md +++ b/README.md @@ -123,5 +123,5 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | Radio type | Firmware | ExpressLRS | |------------|----------|------------| -| Black & white LCD | EdgeTX 2.12+ or 3.0+ | v3.0+ | -| Color LCD | EdgeTX 2.12+ or 3.0+ | v3.0+ | +| Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.0+ | +| Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.0+ | diff --git a/edgetx.yml b/edgetx.yml index ac0e181..1f07df9 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -22,7 +22,7 @@ package: - telemetry license: GPL-3.0-only source_dir: src - min_edgetx_version: "2.12.0" + min_edgetx_version: "2.11.6" libraries: - name: ELRS diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 1892988..0033364 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -5,7 +5,7 @@ ---- # # ---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ---- # # ----- # Unified tool for BW and color LCD radios (EdgeTX 2.12+) # +---- # Unified tool for BW and color LCD radios (EdgeTX 2.11.6+/2.12.1+) # ---- ######################################################################### local VERSION = "r2" @@ -145,9 +145,9 @@ local function run(event, touchState) return 2 end - -- UI-specific pre-checks (LVGL: version/availability check; BW: not defined) + -- UI-specific pre-checks (version check on both LVGL and BW paths) if UI.preCheck then - local result = UI.preCheck() + local result = UI.preCheck(event) if result ~= nil then return result end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 21c09cc..5131a88 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -10,6 +10,23 @@ local Navigation = deps.Navigation local Protocol = deps.Protocol local VERSION = deps.VERSION +local VERSION_CHECK_ENABLED = true +local versionCheckResult = nil + +local function checkEdgeTxVersion() + local _ver, _radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor == 12 and rev >= 1 then + return true + elseif maj == 2 and minor == 11 and rev >= 6 then + return true + end + + return false +end + -- ============================================================================ -- UI state -- ============================================================================ @@ -64,6 +81,31 @@ function UI.init() UI.COL1 = 0 UI.textYoffset = 3 UI.textSize = 8 + + if VERSION_CHECK_ENABLED then + versionCheckResult = checkEdgeTxVersion() + end +end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(event) + if versionCheckResult == false then + UI.drawAlert("EdgeTX Unsupported", { + "Requires EdgeTX:", + "- 2.11.6 or later", + "- 2.12.1 or later", + "- 3.0 or later", + }) + if event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return 2 + end + return 0 + end + return nil end -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 26e3f14..380427b 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -1,6 +1,6 @@ ---- ######################################################################### ---- # LVGL UI: Color LCD rendering, dialogs, command pages # ----- # For color LCD radios with EdgeTX 2.12+ LVGL support # +---- # For color LCD radios with EdgeTX 2.12.1+ LVGL support # ---- ######################################################################### local deps = ... @@ -334,11 +334,13 @@ end local versionCheckResult = nil local function checkEdgeTxVersion() - local ver, _radio, maj, minor, rev = getVersion() + local _ver, _radio, maj, minor, rev = getVersion() if maj >= 3 then return true - elseif maj == 2 and minor >= 12 then + elseif maj == 2 and minor == 12 and rev >= 1 then + return true + elseif maj == 2 and minor == 11 and rev >= 6 then return true end @@ -365,7 +367,8 @@ local function showVersionRequired() flexPad = lvgl.PAD_SMALL, children = { { type = "label", text = "Requires EdgeTX:" }, - { type = "label", text = "- 2.12 or later" }, + { type = "label", text = "- 2.11.6 or later" }, + { type = "label", text = "- 2.12.1 or later" }, { type = "label", text = "- 3.0 or later" }, }, }, @@ -389,37 +392,21 @@ local function showVersionRequired() }) end -local function showLvglRequired() - lcd.clear() - lcd.drawText(5, 10, "LVGL support required", BOLD) - lcd.drawText(5, 20, "Color LCD radio with", 0) - lcd.drawText(5, 30, "EdgeTX 2.12+ or 3.0+", 0) - lcd.drawText(5, 40, "needed", 0) -end - -- ============================================================================ -- Interface: init -- ============================================================================ function UI.init() - if lvgl == nil then - return - end if VERSION_CHECK_ENABLED then versionCheckResult = checkEdgeTxVersion() end end -- ============================================================================ --- Interface: preCheck (LVGL-specific: version/availability) +-- Interface: preCheck (version gate) -- ============================================================================ -function UI.preCheck() - if lvgl == nil then - showLvglRequired() - return 0 - end - +function UI.preCheck(_event) if versionCheckResult == false then if not UI.uiBuilt then showVersionRequired() From d1f97467f2da2d1174f41cda255201391c5120eb Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 13 May 2026 16:56:44 +0300 Subject: [PATCH 095/218] update title for bw unsupported alert --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 5131a88..a6b0a7c 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -93,7 +93,7 @@ end function UI.preCheck(event) if versionCheckResult == false then - UI.drawAlert("EdgeTX Unsupported", { + UI.drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", "- 2.12.1 or later", From 710b6217c505112ddb8d5adeb616d1c982103bc3 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 13 May 2026 17:00:48 +0300 Subject: [PATCH 096/218] migrate away from ExpressLRS1 name to ExpressLRS for tool --- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 0033364..257e115 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -1,4 +1,4 @@ --- TNS|ExpressLRS1|TNE +-- TNS|ExpressLRS|TNE ---- ######################################################################### ---- # # ---- # Copyright (C) OpenTX, adapted for ExpressLRS # From 20d6d5438592c300374783d615a7d474dd7ded01 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 16 May 2026 10:02:05 +0300 Subject: [PATCH 097/218] update README CRSF Simulator section for src/ location --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b73eaa3..92fda1f 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,9 @@ The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over you ## CRSF Simulator (Testing) -The `test/` directory contains a CRSF protocol simulator for development and testing without real hardware. +The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. -**File:** `test/SCRIPTS/CRSFSimulator/csrfsimulator.lua` +**File:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. @@ -103,7 +103,7 @@ The simulator provides a packet-level mock of `crossfireTelemetryPop` and `cross When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. -To use the simulator, copy the `test/` directory contents onto the SD card alongside `src/` so that `SCRIPTS/CRSFSimulator/csrfsimulator.lua` is present. The simulator is ignored on real hardware. +Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to real hardware. ### Scenarios From 4bd6a6935042f678700136194c22f31f874b1716 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 16 May 2026 10:11:06 +0300 Subject: [PATCH 098/218] update edgetx-cli package location --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92fda1f..c346bf3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. You can also install this package using [edgetx-cli](https://github.com/jurgelenas/edgetx-cli): ```sh -edgetx-cli pkg install ExpressLRS/Lua-Scripts@unified-lua-lsp +edgetx-cli pkg install ExpressLRS/Lua-Scripts ``` Use the `--eject` flag to automatically unmount the SD card after installation. From 72b91c2a5786b1000a3a429816f7766d73ff9286 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sat, 16 May 2026 10:18:05 +0300 Subject: [PATCH 099/218] split development docs out of README into docs/development.md Move the Architecture table and CRSF Simulator section out of the user-facing README and into a new docs/development.md. Add a Make targets reference covering setup, quality checks, and deployment. --- README.md | 39 ++----------------------- docs/development.md | 70 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 37 deletions(-) create mode 100644 docs/development.md diff --git a/README.md b/README.md index c346bf3..c1b0438 100644 --- a/README.md +++ b/README.md @@ -62,17 +62,6 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t ExpressLRS Configuration Tool -### Architecture - -| Module | Purpose | -|--------|---------| -| `main.lua` | Entry point and run-loop orchestrator | -| `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | -| `navigation.lua` | Folder and device navigation stack | -| `shim.lua` | Polyfills for BW radios missing standard Lua functions | -| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | -| `ui/lcd.lua` | BW LCD interface (text cursor, popups) | - ## Widgets Both widgets running side-by-side on the home screen: @@ -91,33 +80,9 @@ The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over you VTX Administrator Widget -## CRSF Simulator (Testing) - -The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. - -**File:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` - -The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. - -### How it works - -When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. - -Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to real hardware. - -### Scenarios - -The simulator supports multiple test scenarios, configurable via the `config.scenario` variable at the top of the file: +## Development -| Scenario | Description | -|----------|-------------| -| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | -| `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | -| `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | -| `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | -| `armed` | TX + RX connected with "is Armed" warning flag. | -| `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | -| `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | +See [docs/development.md](docs/development.md) for the tool's internal architecture and the CRSF simulator used for testing inside the EdgeTX simulator. ## Compatibility diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..ca6a9ae --- /dev/null +++ b/docs/development.md @@ -0,0 +1,70 @@ +# Development + +This document covers the internal architecture of the ExpressLRS configuration tool and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware. + +## Make targets + +Run `make help` to list all targets. The Makefile groups them into three categories: + +### Setup + +| Target | Purpose | +|--------|---------| +| `install-tools` | Install both `stylua` and `lua-language-server` | +| `install-stylua` | Install [stylua](https://github.com/JohnnyMorganz/StyLua) via `cargo` (requires Rust toolchain). Built with the `lua53` feature for EdgeTX compatibility. | +| `install-luals` | Download [lua-language-server](https://github.com/LuaLS/lua-language-server) `3.17.1` to `bin/lua-language-server/` | + +### Quality checks + +| Target | Purpose | +|--------|---------| +| `format` | Format all Lua sources in `src/` with `stylua` (config: `.stylua.toml`) | +| `format-check` | Verify formatting without modifying files. Used in CI. | +| `typecheck` | Run `lua-language-server --check .` against the project (config: `.luarc.json`) | +| `check` | Convenience target: runs `format-check` then `typecheck` | + +### Deployment + +| Target | Purpose | +|--------|---------| +| `sync` | Copy sources to the EdgeTX simulator SD card at `../edgetx-sdcard` via `edgetx-cli dev sync`. Includes dev-only libraries like the CRSF simulator. | +| `push` | Install the package to a connected EdgeTX radio via `edgetx-cli pkg install . --eject`. Excludes dev-only libraries. | + +## Architecture + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and run-loop orchestrator | +| `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | +| `navigation.lua` | Folder and device navigation stack | +| `shim.lua` | Polyfills for BW radios missing standard Lua functions | +| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | +| `ui/lcd.lua` | BW LCD interface (text cursor, popups) | + +## CRSF Simulator + +The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. + +**File:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` + +The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. + +### How it works + +When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. + +Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to real hardware. + +### Scenarios + +The simulator supports multiple test scenarios, configurable via the `config.scenario` variable at the top of the file: + +| Scenario | Description | +|----------|-------------| +| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | +| `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | +| `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | +| `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | +| `armed` | TX + RX connected with "is Armed" warning flag. | +| `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | +| `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | From 10d7f30ce3106a5fd8034572afca89a7872102cd Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 16 Jun 2026 21:20:00 +0300 Subject: [PATCH 100/218] show device status text while a COMMAND parameter executes LVGL command popup now renders the live info string the device sends on each CMD_QUERY poll, via a getInfo getter passed into CommandPage (keeps the page decoupled from Protocol). Add a multi-step progress command to the CRSF simulator so the polling status updates can be tested. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 16 +++++++++- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 33 +++++++++++++++++---- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 3129973..feec800 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -547,6 +547,9 @@ local txDevice = { status = CRSF.CMD_IDLE, timeout = 50, info = "", + -- Emits a different status string on each CMD_QUERY poll so the UI can be + -- checked for live status updates while a command is executing. + progress = { "Binding...", "Waiting for RX...", "RX found", "Saving..." }, }, -- Editable string field @@ -1025,10 +1028,17 @@ local function handleCommandWrite(device, param, newStatus) -- Go straight to executing (matches real ELRS firmware behavior: -- most commands skip confirmation and execute immediately) state.status = CRSF.CMD_EXECUTING - state.info = "Executing..." if param.persistent then + state.info = "Executing..." state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi) + elseif param.progress then + -- Step through the status strings, one per CMD_QUERY poll, so the + -- updated info text from the device can be observed in the UI. + state.progressIndex = 1 + state.info = param.progress[1] + state.queriesRemaining = #param.progress else + state.info = "Executing..." state.queriesRemaining = COMMAND_EXECUTE_POLLS end end @@ -1043,6 +1053,10 @@ local function handleCommandWrite(device, param, newStatus) if state.queriesRemaining <= 0 then state.status = CRSF.CMD_IDLE state.info = "Complete" + elseif param.progress then + -- Advance to the next status string for this poll. + state.progressIndex = (state.progressIndex or 1) + 1 + state.info = param.progress[state.progressIndex] or state.info end end end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 380427b..a2bb331 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -191,7 +191,7 @@ local function createSpinner(parent) }) end -function CommandPage.showConfirm(name, info, onConfirm, onCancel) +function CommandPage.showConfirm(name, getInfo, onConfirm, onCancel) lvgl.clear() local pg = lvgl.page({ title = "ExpressLRS", @@ -226,7 +226,8 @@ function CommandPage.showConfirm(name, info, onConfirm, onCancel) w = lvgl.PERCENT_SIZE + 100, align = CENTER, color = COLOR_THEME_DISABLED, - text = info or "", + -- Prompt supplied by the caller as a getter so it refreshes each frame + text = getInfo, }, { type = lvgl.RECTANGLE, @@ -261,7 +262,7 @@ function CommandPage.showConfirm(name, info, onConfirm, onCancel) return pg end -function CommandPage.showExecuting(title, onCancel) +function CommandPage.showExecuting(title, getInfo, onCancel) lvgl.clear() local pg = lvgl.page({ title = "ExpressLRS", @@ -287,6 +288,21 @@ function CommandPage.showExecuting(title, onCancel) }) createSpinner(container) container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + -- Live status text the device sends back while the command runs. + -- Supplied by the caller as a getter so each CMD_QUERY poll response is shown. + type = lvgl.LABEL, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + font = BOLD, + text = getInfo, + }, { type = lvgl.RECTANGLE, w = lvgl.PERCENT_SIZE + 100, @@ -538,15 +554,20 @@ local function handleCommandPopup() UI.invalidate() elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then - UI.commandDialog = CommandPage.showConfirm(Protocol.fieldPopup.name, Protocol.fieldPopup.info, function() + local field = Protocol.fieldPopup + UI.commandDialog = CommandPage.showConfirm(field.name, function() + return field.info or "" + end, function() Protocol.commandConfirm() end, onCommandCancel) end Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then - UI.commandDialog = - CommandPage.showExecuting(Protocol.fieldPopup.name or Protocol.fieldPopup.info, onCommandCancel) + local field = Protocol.fieldPopup + UI.commandDialog = CommandPage.showExecuting(field.name, function() + return field.info or "" + end, onCommandCancel) end Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status end From 8b44aa8a43f8551fa313f7f58c5ab9a22a464802 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 17 Jun 2026 10:48:23 +0300 Subject: [PATCH 101/218] reload current page after a COMMAND parameter completes Completion is detected in Protocol.fieldCommandLoad (which nils fieldPopup inside poll(), before UI.render()), so the UI popup handlers' CMD_IDLE branches were unreachable and never reloaded anything. Re-read the command's same-level fields there via reloadRelatedFields so the current page reflects values the command changed, and remove the dead UI completion branches. Add a Gyro folder (Orientation + Detect Orientation command) to the simulated RX so the behaviour is testable in the simulator. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 46 ++++++++++++++++++--- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 8 ++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 8 +--- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 9 +--- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index feec800..f23e1f1 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -596,7 +596,7 @@ local rxDevice = { serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, swVer = 0x00030500, -- 3.5.0 - fieldCount = 22, -- total parameter count + fieldCount = 25, -- total parameter count params = { { id = 1, @@ -723,9 +723,40 @@ local rxDevice = { units = "", }, + -- Gyro folder: exercises a COMMAND that changes a sibling value, so the tool + -- must re-read the current page after the command completes (Lua-Scripts #8). + { id = 19, parent = 0, type = CRSF.FOLDER, name = "Gyro" }, + { + id = 20, + parent = 19, + type = CRSF.TEXT_SELECTION, + name = "Orientation", + options = "Up;Down;Left;Right", + value = 0, + units = "", + }, + { + id = 21, + parent = 19, + type = CRSF.COMMAND, + name = "Detect Orientation", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + -- Progress steps make the executing popup observable before completion. + progress = { "Detecting...", "Reading IMU..." }, + -- On completion, cycle the Orientation value so each run visibly changes it. + onComplete = function(device, findParam) + local o = findParam(device, 20) + if o then + o.value = ((o.value or 0) + 1) % 4 + end + end, + }, + -- Bind Storage & Bind Mode { - id = 19, + id = 22, parent = 0, type = CRSF.TEXT_SELECTION, name = "Bind Storage", @@ -734,7 +765,7 @@ local rxDevice = { units = "", }, { - id = 20, + id = 23, parent = 0, type = CRSF.COMMAND, name = "Enter Bind Mode", @@ -744,10 +775,10 @@ local rxDevice = { }, -- Model Id - { id = 21, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" }, + { id = 24, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" }, -- Info fields - { id = 22, parent = 0, type = CRSF.INFO, name = "RX Version", value = "3.5.0 825ed8" }, + { id = 25, parent = 0, type = CRSF.INFO, name = "RX Version", value = "3.5.0 825ed8" }, }, } @@ -1053,6 +1084,11 @@ local function handleCommandWrite(device, param, newStatus) if state.queriesRemaining <= 0 then state.status = CRSF.CMD_IDLE state.info = "Complete" + -- Command finished naturally: apply any side effects (e.g. a command + -- that updates a sibling value). Not run on CMD_CANCEL. + if param.onComplete then + param.onComplete(device, findParam) + end elseif param.progress then -- Advance to the next status string for this poll. state.progressIndex = (state.progressIndex or 1) + 1 diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 6a7eab2..c251c14 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -464,6 +464,14 @@ function Protocol.fieldCommandLoad(field, data, offset) local info = Protocol.fieldGetStrOrOpts(data, offset + 2) field.info = (info ~= "") and info or nil if field.status == Protocol.CRSF.CMD_IDLE then + -- A command that was actively running just finished (or was cancelled): + -- re-read its same-level fields so the current page reflects any values the + -- command changed. The guard limits this to the active command -- routine + -- loads of idle command fields while browsing (fieldPopup is nil) must not + -- trigger a reload. + if Protocol.fieldPopup == field then + Protocol.reloadRelatedFields(field) + end Protocol.fieldPopup = nil end end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index a6b0a7c..4e72438 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -617,13 +617,7 @@ function UI.drawPopup(event) Protocol.fieldTimeout = getTime() + 200 end - if - Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE - then - popupConfirmation(Protocol.fieldPopup.info or "", "Stopped!", event) - Protocol.reloadAllFields() - Protocol.fieldPopup = nil - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then local result = popupConfirmation(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "OK" then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index a2bb331..3109f99 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -545,14 +545,7 @@ local function handleCommandPopup() return end - if - Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE - then - Protocol.reloadAllFields() - Protocol.fieldPopup = nil - UI.commandDialog = nil - UI.invalidate() - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then local field = Protocol.fieldPopup UI.commandDialog = CommandPage.showConfirm(field.name, function() From 51da9e83c8f5c0378c0397593af8d5d35b516e8a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 17 Jun 2026 12:09:25 +0300 Subject: [PATCH 102/218] reload related fields after editing a string parameter --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 3109f99..fbc1e82 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -920,7 +920,7 @@ function UI.createStringRow(pg, field) set = function(val) field.value = val Protocol.fieldStringSave(field) - Protocol.reloadParentFolder(field) + Protocol.reloadRelatedFields(field) end, active = function() return not field.disabled From 0fdd50aa2ea12f25b96e31b75cba9468cfea4c58 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 17 Jun 2026 12:09:31 +0300 Subject: [PATCH 103/218] add Bind Phrase echo field to simulator for string reload testing --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index f23e1f1..e417bba 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -397,7 +397,7 @@ local txDevice = { serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, swVer = 0x00030500, -- 3.5.0 - fieldCount = 23, -- total parameter count + fieldCount = 24, -- total parameter count params = { { id = 1, @@ -552,6 +552,12 @@ local txDevice = { progress = { "Binding...", "Waiting for RX...", "RX found", "Saving..." }, }, + -- Mirrors the Bind Phrase value. Editing the string below rewrites this INFO field + -- on the device side, so it only updates in the UI if the STRING write reloads its + -- sibling fields (reloadRelatedFields, not reloadParentFolder). Placed directly above + -- Bind Phrase so both stay on screen together while editing. + { id = 24, parent = 0, type = CRSF.INFO, name = "Phrase Echo", value = "default" }, + -- Editable string field { id = 20, @@ -1200,6 +1206,14 @@ local function mockPush(command, data) i = i + 1 end param.value = (#chars > 0) and string.char(table.unpack(chars)) or "" + -- Bind Phrase: mirror into the Phrase Echo INFO sibling so the UI only + -- reflects the change if the STRING write reloads sibling fields. + if param.id == 20 then + local echo = findParam(device, 24) + if echo then + echo.value = param.value + end + end elseif t == CRSF.FLOAT then local v = bit32.lshift(data[4] or 0, 24) + bit32.lshift(data[5] or 0, 16) From 398b7890f5a89246f1e06f36f50fd65b5b155cac Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 17 Jun 2026 12:21:48 +0300 Subject: [PATCH 104/218] show device name at top of root menu list --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index fbc1e82..245b530 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -1055,6 +1055,11 @@ function UI.build() end else local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) + + if currentFolder == nil then + UI.createInfoRow(fieldContainer, { name = "Device name", value = Protocol.deviceName or "Searching..." }) + end + local FOLDERS_PER_ROW = 2 if IS_NARROW then FOLDERS_PER_ROW = 1 @@ -1106,10 +1111,6 @@ function UI.build() UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) end - if currentFolder == nil then - UI.createInfoRow(fieldContainer, { name = "Device name", value = Protocol.deviceName or "Searching..." }) - end - if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then local wrapper = fieldContainer:box({ w = lvgl.PERCENT_SIZE + 100, From be235ede0ed629dd1e54017df3428200ffe7b89f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 24 Jun 2026 10:40:52 +0300 Subject: [PATCH 105/218] fix repo url --- edgetx.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/edgetx.yml b/edgetx.yml index 1f07df9..41f8f21 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -9,7 +9,7 @@ package: - name: Homepage url: "https://www.expresslrs.org" - name: Repository - url: "https://github.com/ExpressLRS/ExpressLRS-Lua-Scripts" + url: "https://github.com/ExpressLRS/Lua-Scripts" screenshots: - screenshots/tool_main.png - screenshots/tool_main_bw.png From 3d2fb260e2f73e93bc124c184eeabbc6d4add78e Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 27 Jun 2026 14:42:12 -0400 Subject: [PATCH 106/218] Add bind-phrase tool script --- src/SCRIPTS/ELRS/crsf.lua | 4 + src/SCRIPTS/TOOLS/elrs-phrase.lua | 258 ++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/SCRIPTS/TOOLS/elrs-phrase.lua diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 32b81ce..37cfbb6 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -22,6 +22,7 @@ local CRSF = {} CRSF.CONST = { -- Addresses + ADDRESS_RX = 0xEC, ADDRESS_TX_MODULE = 0xEE, ADDRESS_HANDSET = 0xEF, ADDRESS_BROADCAST = 0x00, @@ -34,6 +35,9 @@ CRSF.CONST = { FRAMETYPE_PARAMETER_READ = 0x2C, FRAMETYPE_PARAMETER_WRITE = 0x2D, FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_MSP_REQ = 0x7A, + FRAMETYPE_MSP_RESP = 0x7B, + FRAMETYPE_MSP_WRITE = 0x7C, -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses) FIELD_UINT8 = 0, diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua new file mode 100644 index 0000000..652ca2e --- /dev/null +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -0,0 +1,258 @@ +-- ELRS Config Tool (EdgeTX 2.11+ LVGL) +-- Requires useLvgl = true and a color radio + +local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() + +local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver +local bindPhrase = "" +local uidText = "" +local HISTORY_MAX = 5 +local history = {} + +local MSP_ELRS_RXTX_CONFIG = 45 +local ELRS_RXTX_SUBCMD_UID = 0 +local ELRS_RXTX_SUBCMD_BIND_PHRASE = 1 + +local deferCb +local function deferSet(interval, fn, ctx) + deferCb = { + start = getTime(), + interval = interval, + fn = fn, + ctx = ctx + } +end + +local function deferClear() + deferCb = nil +end + +local function deferCheck() + if deferCb == nil then return end + + if getTime() - deferCb.start < deferCb.interval then + return + end + + -- clear the defer first, if the callback wants to set a new one + local oldcb = deferCb + deferCb = nil + oldcb.fn(oldcb.ctx) +end + +local function addToHistory(s) + if s == nil or s == "" then return end + + -- remove this value from the history list if already there + for idx = #history, 1, -1 do + if history[idx] == s then + table.remove(history, idx) + end + end + + table.insert(history, 1, s) + + while #history > HISTORY_MAX do + table.remove(history) + end + + local f = io.open("elrs-phrase.txt", "w") + if f == nil then return end + io.write(f, table.concat(history, '\n')) + io.close(f) +end + +local function loadHistory() + local f = io.open("elrs-phrase.txt", "r") + if f == nil then return end + + history = {} + local all = io.read(f, 64 * HISTORY_MAX) + io.close(f) + + if all == nil or all == "" then return end + + for line in string.gmatch(all, "[^\n]+") do + history[#history + 1] = line + end + + bindPhrase = history[1] or "" +end + +local function onMspResponse(data) + if data[1] == CRSF.CONST.ADDRESS_RADIO_TRANSMITTER + and (data[2] == CRSF.CONST.ADDRESS_RX or data[2] == CRSF.CONST.ADDRESS_TX_MODULE) then + local mspCmd = data[5] + + if mspCmd == MSP_ELRS_RXTX_CONFIG and data[6] == ELRS_RXTX_SUBCMD_UID then + deferClear() + local rxTx = (data[2] == CRSF.CONST.ADDRESS_RX) and "RX" or "TX" + uidText = string.format("%s: %d, %d, %d, %d, %d, %d", + rxTx, data[7], data[8], data[9], data[10], data[11], data[12]) + end + end +end + +local function requestUid() + uidText = "Updating..." + + CRSF.push(CRSF.CONST.FRAMETYPE_MSP_REQ, { + (targetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX, + CRSF.CONST.ADDRESS_RADIO_TRANSMITTER, + 0x30, + 0x01, + MSP_ELRS_RXTX_CONFIG, + ELRS_RXTX_SUBCMD_UID + }) + + -- Retry if no response + deferSet(50, requestUid) +end + +local function sendBindphrase() + if bindPhrase == "" then return end + + local rxTx = (targetIdx == 1) and "Transmitter" or "Receiver" + uidText = "Setting " .. rxTx .. "..." + + local data = { + (targetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX, + CRSF.CONST.ADDRESS_RADIO_TRANSMITTER, + 0x30, + 0x01 + #bindPhrase, + MSP_ELRS_RXTX_CONFIG, + ELRS_RXTX_SUBCMD_BIND_PHRASE } + + -- append the phrase as bytes + for i = 1, #bindPhrase do data[#data+1] = string.byte(bindPhrase, i) end + + CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) + + addToHistory(bindPhrase) + -- refresh the UID in 1000ms + deferSet(100, requestUid) +end + +local rebuildUi +local function history_text(id) + return history[id] +end +local function history_visible(id) + return history_text(id) ~= nil +end +local function history_press(id) + bindPhrase = history[id] + rebuildUi() +end + +rebuildUi = function () + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS Bind Phrase", + subtitle = function() return uidText end, + }) + + local tbox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- ***** Bind Phrase label + text edit + Set button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Bind Phrase", + children = { + { + type = lvgl.BOX, + x = 120, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.TEXT_EDIT, + w = 240, + value = bindPhrase, + length = 52, -- packet is only so big and can't span + set = function(v) bindPhrase = v end, + }, + { + type = lvgl.BUTTON, + text = "Set", + press = sendBindphrase, + }, + }, + }, + }, + }) + + -- ***** Target label + dropdown + Request UID button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Target", + children = { + { + type = lvgl.BOX, + x = 120, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.CHOICE, + title = "Select Target", + values = {"Transmitter", "Receiver"}, + get = function() return targetIdx end, + set = function(n) targetIdx = n end, + }, + { + type = lvgl.BUTTON, + text = "Request UID", + press = requestUid, + }, + }, + }, + }, + }) + + -- ***** Bind Phrase History ***** + local row = pg:box({ + w = lvgl.PERCENT_SIZE + 100, y = 82, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + visible = function () return #history end, + }) + row:label({text = "Bind Phrase History"}) + for i = 1, HISTORY_MAX do + row:button({ + w = lvgl.PERCENT_SIZE + 80, + text = function () return history_text(i) end, + visible = function () return history_visible(i) end, + press = function () return history_press(i) end, + }) + end +end + +local function init() + if lvgl == nil then return end + + loadHistory() + rebuildUi() + + CRSF:registerHandler(CRSF.CONST.FRAMETYPE_MSP_RESP, onMspResponse) + deferSet(1, requestUid) +end + +local function run(event, touchState) + if lvgl == nil then + lcd.drawText(0, 0, "LVGL (EdgeTX 2.11+) required", COLOR_THEME_WARNING) + return 0 + end + + CRSF:poll() + -- Must come after poll so a telemetry queue is established + deferCheck() + + return 0 +end + +return { init = init, run = run, useLvgl = true } From 8f1697b01fb96df59fddfb380fb0b034a8165927 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 27 Jun 2026 14:45:50 -0400 Subject: [PATCH 107/218] Encapsulate history and defer --- src/SCRIPTS/TOOLS/elrs-phrase.lua | 81 ++++++++++++++++--------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua index 652ca2e..2f8eeac 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -6,16 +6,15 @@ local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver local bindPhrase = "" local uidText = "" -local HISTORY_MAX = 5 -local history = {} local MSP_ELRS_RXTX_CONFIG = 45 local ELRS_RXTX_SUBCMD_UID = 0 local ELRS_RXTX_SUBCMD_BIND_PHRASE = 1 -local deferCb -local function deferSet(interval, fn, ctx) - deferCb = { +local Defer = { _deferCb = nil } + +function Defer.setTimeout(interval, fn, ctx) + Defer._deferCb = { start = getTime(), interval = interval, fn = fn, @@ -23,60 +22,66 @@ local function deferSet(interval, fn, ctx) } end -local function deferClear() - deferCb = nil +function Defer.clear() + Defer._deferCb = nil end -local function deferCheck() - if deferCb == nil then return end +function Defer.poll() + if Defer._deferCb == nil then return end - if getTime() - deferCb.start < deferCb.interval then + if getTime() - Defer._deferCb.start < Defer._deferCb.interval then return end -- clear the defer first, if the callback wants to set a new one - local oldcb = deferCb - deferCb = nil + local oldcb = Defer._deferCb + Defer._deferCb = nil oldcb.fn(oldcb.ctx) end -local function addToHistory(s) +local History = { + MAX = 5, + FNAME = "elrs-phrase.txt", + vals = {}, +} + +function History.add(s) if s == nil or s == "" then return end -- remove this value from the history list if already there - for idx = #history, 1, -1 do - if history[idx] == s then - table.remove(history, idx) + for idx = #History.vals, 1, -1 do + if History.vals[idx] == s then + table.remove(History.vals, idx) end end - table.insert(history, 1, s) + table.insert(History.vals, 1, s) - while #history > HISTORY_MAX do - table.remove(history) + while #History.vals > History.MAX do + table.remove(History.vals) end - local f = io.open("elrs-phrase.txt", "w") + local f = io.open(History.FNAME, "w") if f == nil then return end - io.write(f, table.concat(history, '\n')) + io.write(f, table.concat(History.vals, '\n')) io.close(f) end -local function loadHistory() - local f = io.open("elrs-phrase.txt", "r") +function History.load() + local f = io.open(History.FNAME, "r") if f == nil then return end - history = {} - local all = io.read(f, 64 * HISTORY_MAX) + History.vals = {} + local all = io.read(f, 64 * History.MAX) io.close(f) if all == nil or all == "" then return end for line in string.gmatch(all, "[^\n]+") do - history[#history + 1] = line + History.vals[#History.vals + 1] = line end - bindPhrase = history[1] or "" + return History.vals[1] end local function onMspResponse(data) @@ -85,7 +90,7 @@ local function onMspResponse(data) local mspCmd = data[5] if mspCmd == MSP_ELRS_RXTX_CONFIG and data[6] == ELRS_RXTX_SUBCMD_UID then - deferClear() + Defer.clear() local rxTx = (data[2] == CRSF.CONST.ADDRESS_RX) and "RX" or "TX" uidText = string.format("%s: %d, %d, %d, %d, %d, %d", rxTx, data[7], data[8], data[9], data[10], data[11], data[12]) @@ -106,7 +111,7 @@ local function requestUid() }) -- Retry if no response - deferSet(50, requestUid) + Defer.setTimeout(50, requestUid) end local function sendBindphrase() @@ -128,20 +133,20 @@ local function sendBindphrase() CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) - addToHistory(bindPhrase) + History.add(bindPhrase) -- refresh the UID in 1000ms - deferSet(100, requestUid) + Defer.setTimeout(100, requestUid) end local rebuildUi local function history_text(id) - return history[id] + return History.vals[id] end local function history_visible(id) return history_text(id) ~= nil end local function history_press(id) - bindPhrase = history[id] + bindPhrase = History.vals[id] rebuildUi() end @@ -219,10 +224,10 @@ rebuildUi = function () w = lvgl.PERCENT_SIZE + 100, y = 82, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_MEDIUM, - visible = function () return #history end, + visible = function () return #History.vals end, }) row:label({text = "Bind Phrase History"}) - for i = 1, HISTORY_MAX do + for i = 1, History.MAX do row:button({ w = lvgl.PERCENT_SIZE + 80, text = function () return history_text(i) end, @@ -235,11 +240,11 @@ end local function init() if lvgl == nil then return end - loadHistory() + bindPhrase = History.load() or "" rebuildUi() CRSF:registerHandler(CRSF.CONST.FRAMETYPE_MSP_RESP, onMspResponse) - deferSet(1, requestUid) + Defer.setTimeout(1, requestUid) end local function run(event, touchState) @@ -250,7 +255,7 @@ local function run(event, touchState) CRSF:poll() -- Must come after poll so a telemetry queue is established - deferCheck() + Defer.poll() return 0 end From f9056ab1d63ad6f00c144441bf27bb805276e7a8 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 27 Jun 2026 15:11:31 -0400 Subject: [PATCH 108/218] Whitespace --- src/SCRIPTS/TOOLS/elrs-phrase.lua | 59 ++++++++++++++++--------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua index 2f8eeac..be47d3c 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -18,7 +18,7 @@ function Defer.setTimeout(interval, fn, ctx) start = getTime(), interval = interval, fn = fn, - ctx = ctx + ctx = ctx, } end @@ -107,11 +107,11 @@ local function requestUid() 0x30, 0x01, MSP_ELRS_RXTX_CONFIG, - ELRS_RXTX_SUBCMD_UID + ELRS_RXTX_SUBCMD_UID, }) -- Retry if no response - Defer.setTimeout(50, requestUid) + Defer.setTimeout(50, requestUid) end local function sendBindphrase() @@ -126,16 +126,17 @@ local function sendBindphrase() 0x30, 0x01 + #bindPhrase, MSP_ELRS_RXTX_CONFIG, - ELRS_RXTX_SUBCMD_BIND_PHRASE } + ELRS_RXTX_SUBCMD_BIND_PHRASE, + } -- append the phrase as bytes for i = 1, #bindPhrase do data[#data+1] = string.byte(bindPhrase, i) end CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) - History.add(bindPhrase) + History.add(bindPhrase) -- refresh the UID in 1000ms - Defer.setTimeout(100, requestUid) + Defer.setTimeout(100, requestUid) end local rebuildUi @@ -150,7 +151,7 @@ local function history_press(id) rebuildUi() end -rebuildUi = function () +rebuildUi = function() lvgl.clear() local pg = lvgl.page({ @@ -169,21 +170,21 @@ rebuildUi = function () title = "Bind Phrase", children = { { - type = lvgl.BOX, - x = 120, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, + type = lvgl.BOX, + x = 120, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, children = { { - type = lvgl.TEXT_EDIT, - w = 240, - value = bindPhrase, + type = lvgl.TEXT_EDIT, + w = 240, + value = bindPhrase, length = 52, -- packet is only so big and can't span set = function(v) bindPhrase = v end, }, { - type = lvgl.BUTTON, - text = "Set", + type = lvgl.BUTTON, + text = "Set", press = sendBindphrase, }, }, @@ -198,20 +199,20 @@ rebuildUi = function () children = { { type = lvgl.BOX, - x = 120, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, + x = 120, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, children = { { - type = lvgl.CHOICE, - title = "Select Target", + type = lvgl.CHOICE, + title = "Select Target", values = {"Transmitter", "Receiver"}, - get = function() return targetIdx end, - set = function(n) targetIdx = n end, + get = function() return targetIdx end, + set = function(n) targetIdx = n end, }, { - type = lvgl.BUTTON, - text = "Request UID", + type = lvgl.BUTTON, + text = "Request UID", press = requestUid, }, }, @@ -222,9 +223,9 @@ rebuildUi = function () -- ***** Bind Phrase History ***** local row = pg:box({ w = lvgl.PERCENT_SIZE + 100, y = 82, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, - visible = function () return #History.vals end, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + visible = function () return #History.vals end, }) row:label({text = "Bind Phrase History"}) for i = 1, History.MAX do @@ -244,7 +245,7 @@ local function init() rebuildUi() CRSF:registerHandler(CRSF.CONST.FRAMETYPE_MSP_RESP, onMspResponse) - Defer.setTimeout(1, requestUid) + Defer.setTimeout(1, requestUid) end local function run(event, touchState) From 4689638773c7ca530798d5c62e29af4357e69dfc Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 4 Jul 2026 16:34:19 -0400 Subject: [PATCH 109/218] Add Bind command shortcut if RX not connected --- src/SCRIPTS/ELRS/crsf.lua | 24 ++++++++++++++++++++++ src/SCRIPTS/TOOLS/elrs-phrase.lua | 34 +++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 37cfbb6..c5b5698 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -35,6 +35,7 @@ CRSF.CONST = { FRAMETYPE_PARAMETER_READ = 0x2C, FRAMETYPE_PARAMETER_WRITE = 0x2D, FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_COMMAND = 0x32, FRAMETYPE_MSP_REQ = 0x7A, FRAMETYPE_MSP_RESP = 0x7B, FRAMETYPE_MSP_WRITE = 0x7C, @@ -62,6 +63,15 @@ CRSF.CONST = { -- Module type for model.getModule() check MODULE_TYPE_CROSSFIRE = 5, + + -- FRAMETYPE_COMMAND subcommands + COMMAND = { + SUBCMD_RX = { + ID = 0x10, + -- CMDS + BIND = 0x01, + }, + }, } -- ============================================================================ @@ -74,6 +84,9 @@ CRSF._handlers = {} -- Device info cache (populated by built-in DEVICE_INFO handler) CRSF.deviceInfo = {} +-- True if receiving telemetry, set by poll() so do call this before checking +CRSF.isConnected = nil + -- Telemetry state (populated by built-in ELRS_STATUS handler) CRSF.hasTelemetry = false CRSF.modelMismatch = false @@ -202,6 +215,9 @@ function CRSF:poll() end self._lastPollTick = now + local LQ = getValue('RQly') + self.isConnected = LQ and LQ > 0 or nil + while true do local command, data = CRSF.pop() if command == nil then @@ -260,6 +276,14 @@ function CRSF:requestElrsStatus() CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) end +-- Send a BIND command to the dest ADDR (default TX) +-- Sending to RX unbinds if connected, sending to TX transmits a packet to bind a waiting RX +function CRSF.sendBind(dest) + CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, + { dest or CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, CRSF.CONST.COMMAND.SUBCMD_RX.ID, CRSF.CONST.COMMAND.SUBCMD_RX.BIND} + ) +end + -- ============================================================================ -- Built-in handlers -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua index be47d3c..e1f58f6 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -139,6 +139,12 @@ local function sendBindphrase() Defer.setTimeout(100, requestUid) end +function sendBindTx() + uidText = "Sending bind command..." + CRSF.sendBind(ADDRESS_TX_MODULE) + Defer.setTimeout(100, function () uidText = "Sent" end) +end + local rebuildUi local function history_text(id) return History.vals[id] @@ -186,6 +192,7 @@ rebuildUi = function() type = lvgl.BUTTON, text = "Set", press = sendBindphrase, + active = function () return targetIdx == 1 or CRSF.isConnected end, }, }, }, @@ -198,7 +205,7 @@ rebuildUi = function() title = "Target", children = { { - type = lvgl.BOX, + type = lvgl.BOX, x = 120, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, @@ -214,18 +221,41 @@ rebuildUi = function() type = lvgl.BUTTON, text = "Request UID", press = requestUid, + active = function () return targetIdx == 1 or CRSF.isConnected end, }, }, }, }, }) + -- ***** Show Bind button if RX target selected and no RX connected ***** + pg:box({ + w = lvgl.PERCENT_SIZE + 100, y = 82, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + visible = function () return targetIdx == 2 and not CRSF.isConnected end, + children = { + { + type = lvgl.LABEL, + w = 4+120+240, + text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", + }, + { + type = lvgl.BUTTON, + text = "Bind", + press = sendBindTx, + }, + }, + }) + -- ***** Bind Phrase History ***** local row = pg:box({ w = lvgl.PERCENT_SIZE + 100, y = 82, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_MEDIUM, - visible = function () return #History.vals end, + -- visible if there is history and TX selected or RX selected and isConnected + visible = function () return #History.vals and targetIdx == 1 or CRSF.isConnected end, }) row:label({text = "Bind Phrase History"}) for i = 1, History.MAX do From 63a83e7508a66c28470f388e7b67818d7971eef7 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 4 Jul 2026 16:41:41 -0400 Subject: [PATCH 110/218] Include bindphrase textedit as disabled when RX is unreachable, do not poll if unreachable --- src/SCRIPTS/TOOLS/elrs-phrase.lua | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua index e1f58f6..edd6292 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -98,7 +98,16 @@ local function onMspResponse(data) end end +local function isTargetReachable() + return targetIdx == 1 or CRSF.isConnected +end + local function requestUid() + if not isTargetReachable() then + uidText = "Idle" + return + end + uidText = "Updating..." CRSF.push(CRSF.CONST.FRAMETYPE_MSP_REQ, { @@ -139,7 +148,7 @@ local function sendBindphrase() Defer.setTimeout(100, requestUid) end -function sendBindTx() +local function sendBindTx() uidText = "Sending bind command..." CRSF.sendBind(ADDRESS_TX_MODULE) Defer.setTimeout(100, function () uidText = "Sent" end) @@ -187,12 +196,13 @@ rebuildUi = function() value = bindPhrase, length = 52, -- packet is only so big and can't span set = function(v) bindPhrase = v end, + active = isTargetReachable, }, { type = lvgl.BUTTON, text = "Set", press = sendBindphrase, - active = function () return targetIdx == 1 or CRSF.isConnected end, + active = isTargetReachable, }, }, }, @@ -221,7 +231,7 @@ rebuildUi = function() type = lvgl.BUTTON, text = "Request UID", press = requestUid, - active = function () return targetIdx == 1 or CRSF.isConnected end, + active = isTargetReachable, }, }, }, @@ -255,7 +265,7 @@ rebuildUi = function() flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_MEDIUM, -- visible if there is history and TX selected or RX selected and isConnected - visible = function () return #History.vals and targetIdx == 1 or CRSF.isConnected end, + visible = function () return #History.vals and isTargetReachable() end, }) row:label({text = "Bind Phrase History"}) for i = 1, History.MAX do From 793e875661ee2e19bb4d20d2c356ef9b01eba7f7 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 4 Jul 2026 16:52:18 -0400 Subject: [PATCH 111/218] Include RX unbind button too why not --- src/SCRIPTS/TOOLS/elrs-phrase.lua | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-phrase.lua index edd6292..283a325 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-phrase.lua @@ -150,7 +150,13 @@ end local function sendBindTx() uidText = "Sending bind command..." - CRSF.sendBind(ADDRESS_TX_MODULE) + CRSF.sendBind(CRSF.CONST.ADDRESS_TX_MODULE) + Defer.setTimeout(100, function () uidText = "Sent" end) +end + +local function sendBindRx() + uidText = "Sending unbind to RX..." + CRSF.sendBind(CRSF.CONST.ADDRESS_RX) Defer.setTimeout(100, function () uidText = "Sent" end) end @@ -233,6 +239,15 @@ rebuildUi = function() press = requestUid, active = isTargetReachable, }, + { + type = lvgl.BUTTON, + text = "Unbind", + press = sendBindRx, + -- Visible if RX + visible = function () return targetIdx == 2 end, + -- Active if RX and is connected + active = function () return targetIdx == 2 and CRSF.isConnected end, + }, }, }, }, From 23bd15ee88015ea88e213f1c255320d229f02209 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 5 Jul 2026 14:16:06 +0300 Subject: [PATCH 112/218] support renamed Band/Enable field label in VTX Admin widget ExpressLRS PR #3608 renamed the Band field to Band/Enable and its Off option to Disabled. Match both labels during field discovery so the widget works on firmware before and after the rename, and update the CRSF simulator to mirror the new firmware parameters. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 6 +++--- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index e417bba..3a501bc 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -481,8 +481,8 @@ local txDevice = { id = 11, parent = 10, type = CRSF.TEXT_SELECTION, - name = "Band", - options = "Off;A;B;E;F;R;L", + name = "Band/Enable", + options = "Disabled;A;B;E;F;R;L", value = 5, units = "", }, @@ -861,7 +861,7 @@ local function updateFolderNames(device) if vtxFolder and vtxBand then local bandVal = vtxBand.value or 0 if bandVal == 0 then - -- Band is "Off" -> use static name (no dynamic suffix) + -- Band is "Disabled" -> use static name (no dynamic suffix) vtxFolder.dynName = nil else local bandLabel = getOptionLabel(vtxBand.options, bandVal) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 92829bf..f282b1f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -255,7 +255,7 @@ function Protocol.onSettingsEntry(data) Protocol.statusText = "VTX Admin not found" end elseif st == Protocol.STATE_DISCOVER_VTX then - if fieldName == "Band" then + if fieldName == "Band" or fieldName == "Band/Enable" then VTX.ids.band = fieldId elseif fieldName == "Channel" then VTX.ids.channel = fieldId From ba04bb1db8f3a1495f7d071642fefeac4f1058f6 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Mon, 6 Jul 2026 14:58:57 -0400 Subject: [PATCH 113/218] Rename to Bind Manager, scale for LCD size --- .../TOOLS/{elrs-phrase.lua => elrs-bind.lua} | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) rename src/SCRIPTS/TOOLS/{elrs-phrase.lua => elrs-bind.lua} (95%) diff --git a/src/SCRIPTS/TOOLS/elrs-phrase.lua b/src/SCRIPTS/TOOLS/elrs-bind.lua similarity index 95% rename from src/SCRIPTS/TOOLS/elrs-phrase.lua rename to src/SCRIPTS/TOOLS/elrs-bind.lua index 283a325..2cdf221 100644 --- a/src/SCRIPTS/TOOLS/elrs-phrase.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind.lua @@ -1,5 +1,4 @@ --- ELRS Config Tool (EdgeTX 2.11+ LVGL) --- Requires useLvgl = true and a color radio +-- TNS|ELRS Bind Manager|TNE local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() @@ -188,17 +187,17 @@ rebuildUi = function() -- ***** Bind Phrase label + text edit + Set button ***** tbox:setting({ w = lvgl.PERCENT_SIZE + 100, - title = "Bind Phrase", + title = "Bind phrase", children = { { type = lvgl.BOX, - x = 120, + x = 120 * lvgl.LCD_SCALE, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, children = { { type = lvgl.TEXT_EDIT, - w = 240, + w = 250 * lvgl.LCD_SCALE, value = bindPhrase, length = 52, -- packet is only so big and can't span set = function(v) bindPhrase = v end, @@ -222,7 +221,7 @@ rebuildUi = function() children = { { type = lvgl.BOX, - x = 120, + x = 120 * lvgl.LCD_SCALE, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, children = { @@ -255,7 +254,7 @@ rebuildUi = function() -- ***** Show Bind button if RX target selected and no RX connected ***** pg:box({ - w = lvgl.PERCENT_SIZE + 100, y = 82, + w = lvgl.PERCENT_SIZE + 100, y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, align = LEFT, @@ -263,7 +262,7 @@ rebuildUi = function() children = { { type = lvgl.LABEL, - w = 4+120+240, + w = 4 + lvgl.LCD_SCALE * (120 + 250), text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", }, { @@ -276,7 +275,7 @@ rebuildUi = function() -- ***** Bind Phrase History ***** local row = pg:box({ - w = lvgl.PERCENT_SIZE + 100, y = 82, + w = lvgl.PERCENT_SIZE + 100, y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_MEDIUM, -- visible if there is history and TX selected or RX selected and isConnected From eb48f56921c073b3d60a027f58b6d12cace3192c Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Mon, 6 Jul 2026 15:01:20 -0400 Subject: [PATCH 114/218] Rename history file as well --- src/SCRIPTS/TOOLS/elrs-bind.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/elrs-bind.lua b/src/SCRIPTS/TOOLS/elrs-bind.lua index 2cdf221..df22b97 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind.lua @@ -40,7 +40,7 @@ end local History = { MAX = 5, - FNAME = "elrs-phrase.txt", + FNAME = "elrs-bind.txt", vals = {}, } From b2940b10aa5bc3239e0eeb10bd959e758b4307f7 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Tue, 7 Jul 2026 10:45:49 -0400 Subject: [PATCH 115/218] Adjust formatting to make stylua stop emailing me --- src/SCRIPTS/ELRS/crsf.lua | 11 ++- src/SCRIPTS/TOOLS/elrs-bind.lua | 128 ++++++++++++++++++++++---------- 2 files changed, 95 insertions(+), 44 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index c5b5698..47738ec 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -215,7 +215,7 @@ function CRSF:poll() end self._lastPollTick = now - local LQ = getValue('RQly') + local LQ = getValue("RQly") self.isConnected = LQ and LQ > 0 or nil while true do @@ -279,9 +279,12 @@ end -- Send a BIND command to the dest ADDR (default TX) -- Sending to RX unbinds if connected, sending to TX transmits a packet to bind a waiting RX function CRSF.sendBind(dest) - CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, - { dest or CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, CRSF.CONST.COMMAND.SUBCMD_RX.ID, CRSF.CONST.COMMAND.SUBCMD_RX.BIND} - ) + CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, { + dest or CRSF.CONST.ADDRESS_TX_MODULE, + CRSF.CONST.ADDRESS_HANDSET, + CRSF.CONST.COMMAND.SUBCMD_RX.ID, + CRSF.CONST.COMMAND.SUBCMD_RX.BIND, + }) end -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/elrs-bind.lua b/src/SCRIPTS/TOOLS/elrs-bind.lua index df22b97..4df4c44 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind.lua @@ -2,9 +2,9 @@ local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver -local bindPhrase = "" -local uidText = "" +local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver +local bindPhrase = "" +local uidText = "" local MSP_ELRS_RXTX_CONFIG = 45 local ELRS_RXTX_SUBCMD_UID = 0 @@ -26,7 +26,9 @@ function Defer.clear() end function Defer.poll() - if Defer._deferCb == nil then return end + if Defer._deferCb == nil then + return + end if getTime() - Defer._deferCb.start < Defer._deferCb.interval then return @@ -45,7 +47,9 @@ local History = { } function History.add(s) - if s == nil or s == "" then return end + if s == nil or s == "" then + return + end -- remove this value from the history list if already there for idx = #History.vals, 1, -1 do @@ -61,20 +65,26 @@ function History.add(s) end local f = io.open(History.FNAME, "w") - if f == nil then return end - io.write(f, table.concat(History.vals, '\n')) + if f == nil then + return + end + io.write(f, table.concat(History.vals, "\n")) io.close(f) end function History.load() local f = io.open(History.FNAME, "r") - if f == nil then return end + if f == nil then + return + end History.vals = {} local all = io.read(f, 64 * History.MAX) io.close(f) - if all == nil or all == "" then return end + if all == nil or all == "" then + return + end for line in string.gmatch(all, "[^\n]+") do History.vals[#History.vals + 1] = line @@ -84,21 +94,23 @@ function History.load() end local function onMspResponse(data) - if data[1] == CRSF.CONST.ADDRESS_RADIO_TRANSMITTER - and (data[2] == CRSF.CONST.ADDRESS_RX or data[2] == CRSF.CONST.ADDRESS_TX_MODULE) then + if + data[1] == CRSF.CONST.ADDRESS_RADIO_TRANSMITTER + and (data[2] == CRSF.CONST.ADDRESS_RX or data[2] == CRSF.CONST.ADDRESS_TX_MODULE) + then local mspCmd = data[5] if mspCmd == MSP_ELRS_RXTX_CONFIG and data[6] == ELRS_RXTX_SUBCMD_UID then Defer.clear() local rxTx = (data[2] == CRSF.CONST.ADDRESS_RX) and "RX" or "TX" - uidText = string.format("%s: %d, %d, %d, %d, %d, %d", - rxTx, data[7], data[8], data[9], data[10], data[11], data[12]) + uidText = + string.format("%s: %d, %d, %d, %d, %d, %d", rxTx, data[7], data[8], data[9], data[10], data[11], data[12]) end end end local function isTargetReachable() - return targetIdx == 1 or CRSF.isConnected + return targetIdx == 1 or CRSF.isConnected end local function requestUid() @@ -123,7 +135,9 @@ local function requestUid() end local function sendBindphrase() - if bindPhrase == "" then return end + if bindPhrase == "" then + return + end local rxTx = (targetIdx == 1) and "Transmitter" or "Receiver" uidText = "Setting " .. rxTx .. "..." @@ -138,7 +152,9 @@ local function sendBindphrase() } -- append the phrase as bytes - for i = 1, #bindPhrase do data[#data+1] = string.byte(bindPhrase, i) end + for i = 1, #bindPhrase do + data[#data + 1] = string.byte(bindPhrase, i) + end CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) @@ -150,13 +166,17 @@ end local function sendBindTx() uidText = "Sending bind command..." CRSF.sendBind(CRSF.CONST.ADDRESS_TX_MODULE) - Defer.setTimeout(100, function () uidText = "Sent" end) + Defer.setTimeout(100, function() + uidText = "Sent" + end) end local function sendBindRx() uidText = "Sending unbind to RX..." CRSF.sendBind(CRSF.CONST.ADDRESS_RX) - Defer.setTimeout(100, function () uidText = "Sent" end) + Defer.setTimeout(100, function() + uidText = "Sent" + end) end local rebuildUi @@ -175,8 +195,10 @@ rebuildUi = function() lvgl.clear() local pg = lvgl.page({ - title = "ExpressLRS Bind Phrase", - subtitle = function() return uidText end, + title = "ExpressLRS Bind Phrase", + subtitle = function() + return uidText + end, }) local tbox = pg:box({ @@ -200,14 +222,18 @@ rebuildUi = function() w = 250 * lvgl.LCD_SCALE, value = bindPhrase, length = 52, -- packet is only so big and can't span - set = function(v) bindPhrase = v end, + set = function(v) + bindPhrase = v + end, active = isTargetReachable, }, { type = lvgl.BUTTON, text = "Set", press = sendBindphrase, - active = isTargetReachable, + active = function() + return isTargetReachable() and bindPhrase ~= "" + end, }, }, }, @@ -228,9 +254,13 @@ rebuildUi = function() { type = lvgl.CHOICE, title = "Select Target", - values = {"Transmitter", "Receiver"}, - get = function() return targetIdx end, - set = function(n) targetIdx = n end, + values = { "Transmitter", "Receiver" }, + get = function() + return targetIdx + end, + set = function(n) + targetIdx = n + end, }, { type = lvgl.BUTTON, @@ -243,9 +273,13 @@ rebuildUi = function() text = "Unbind", press = sendBindRx, -- Visible if RX - visible = function () return targetIdx == 2 end, + visible = function() + return targetIdx == 2 + end, -- Active if RX and is connected - active = function () return targetIdx == 2 and CRSF.isConnected end, + active = function() + return targetIdx == 2 and CRSF.isConnected + end, }, }, }, @@ -254,11 +288,14 @@ rebuildUi = function() -- ***** Show Bind button if RX target selected and no RX connected ***** pg:box({ - w = lvgl.PERCENT_SIZE + 100, y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, align = LEFT, - visible = function () return targetIdx == 2 and not CRSF.isConnected end, + visible = function() + return targetIdx == 2 and not CRSF.isConnected + end, children = { { type = lvgl.LABEL, @@ -275,25 +312,36 @@ rebuildUi = function() -- ***** Bind Phrase History ***** local row = pg:box({ - w = lvgl.PERCENT_SIZE + 100, y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_COLUMN, flexPad = lvgl.PAD_MEDIUM, -- visible if there is history and TX selected or RX selected and isConnected - visible = function () return #History.vals and isTargetReachable() end, + visible = function() + return #History.vals and isTargetReachable() + end, }) - row:label({text = "Bind Phrase History"}) - for i = 1, History.MAX do - row:button({ - w = lvgl.PERCENT_SIZE + 80, - text = function () return history_text(i) end, - visible = function () return history_visible(i) end, - press = function () return history_press(i) end, - }) + row:label({ text = "Bind Phrase History" }) + for i = 1, History.MAX do + row:button({ + w = lvgl.PERCENT_SIZE + 80, + text = function() + return history_text(i) + end, + visible = function() + return history_visible(i) + end, + press = function() + return history_press(i) + end, + }) end end local function init() - if lvgl == nil then return end + if lvgl == nil then + return + end bindPhrase = History.load() or "" rebuildUi() From 9fbaf4277276a88ec2aae67c8bfcde94212d0362 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 9 Jul 2026 21:43:49 +0300 Subject: [PATCH 116/218] unify CRSF address constants: ADDRESS_HANDSET, ADDRESS_RX, ADDRESS_TX, ADDRESS_HANDSET_ELRS --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 30 +++++++++---------- src/SCRIPTS/ELRS/crsf.lua | 13 +++++---- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 4 +-- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 32 +++++++++------------ src/WIDGETS/ELRSVTXAdmin/loadable.lua | 9 ++---- 5 files changed, 41 insertions(+), 47 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 3a501bc..fd3d702 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -50,10 +50,10 @@ local CRSF = { -- Addresses ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, + ADDRESS_HANDSET = 0xEA, -- EdgeTX's official handset address + ADDRESS_RX = 0xEC, + ADDRESS_TX = 0xEE, + ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard CRSF -- Field types0 UINT8 = 0, @@ -226,11 +226,11 @@ end --- Encode a DEVICE_INFO response packet (frame type 0x29) -- @param device table with: id, name, serialNo, hwVer, swVer, fieldCount --- @param destAddr destination address (usually ADDRESS_RADIO_TRANSMITTER) +-- @param destAddr destination address (usually ADDRESS_HANDSET) -- @return data table suitable for queuePush(FRAMETYPE_DEVICE_INFO, data) local function encodeDeviceInfo(device, destAddr) local data = {} - data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[1] = destAddr or CRSF.ADDRESS_HANDSET data[2] = device.id -- Device name (null-terminated) appendString(data, device.name) @@ -256,7 +256,7 @@ end -- @return data table suitable for queuePush(FRAMETYPE_PARAMETER_SETTINGS_ENTRY, data) local function encodeParameterEntry(device, param, _chunk, destAddr) local data = {} - data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[1] = destAddr or CRSF.ADDRESS_HANDSET data[2] = device.id data[3] = param.id -- Field ID data[4] = 0 -- Chunks remaining (0 = single chunk) @@ -375,7 +375,7 @@ end -- @return data table local function encodeElrsStatus(deviceId, destAddr, badPkts, goodPkts, flags, flagsInfo) local data = {} - data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[1] = destAddr or CRSF.ADDRESS_HANDSET data[2] = deviceId data[3] = badPkts or 0 -- Good packets as uint16 BE @@ -392,7 +392,7 @@ end -- ============================================================================ local txDevice = { - id = CRSF.ADDRESS_CRSF_TRANSMITTER, + id = CRSF.ADDRESS_TX, name = "TX16S MK3", serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, @@ -597,7 +597,7 @@ local txDevice = { -- ============================================================================ local rxDevice = { - id = CRSF.ADDRESS_CRSF_RECEIVER, + id = CRSF.ADDRESS_RX, name = "ELRS 2400RX", serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, @@ -1118,7 +1118,7 @@ local function mockPush(command, data) end if command == CRSF.FRAMETYPE_DEVICE_PING then - local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + local destAddr = data[2] or CRSF.ADDRESS_HANDSET -- TX module responds immediately (local to handset) queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, destAddr)) @@ -1135,7 +1135,7 @@ local function mockPush(command, data) local deviceId = data[1] local fieldId = data[3] local chunk = data[4] or 0 - local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + local destAddr = data[2] or CRSF.ADDRESS_HANDSET local device = findDeviceByAddr(deviceId) if device then @@ -1180,7 +1180,7 @@ local function mockPush(command, data) if fieldId == 0 then local flags = getElrsFlags() local flagsInfo = getElrsFlagsInfo() - local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + local destAddr = data[2] or CRSF.ADDRESS_HANDSET queuePush(CRSF.FRAMETYPE_ELRS_STATUS, encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) return true end @@ -1194,7 +1194,7 @@ local function mockPush(command, data) -- Command: handle state machine handleCommandWrite(device, param, writeValue) -- Queue the updated parameter entry as response - local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + local destAddr = data[2] or CRSF.ADDRESS_HANDSET queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, encodeParameterEntry(device, param, 0, destAddr)) else -- Value write: decode based on field type @@ -1235,7 +1235,7 @@ local function mockPush(command, data) -- Output Mapping per-channel config: mimic firmware behavior -- where changing Output Ch loads sibling values from per-channel config, -- and editing siblings saves back to the current channel's config. - if device.id == CRSF.ADDRESS_CRSF_RECEIVER then + if device.id == CRSF.ADDRESS_RX then if param.id == 9 then -- Output Ch changed: load config for the selected channel local cfg = pwmChannelConfig[param.value] diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 32b81ce..94df53e 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -22,10 +22,11 @@ local CRSF = {} CRSF.CONST = { -- Addresses - ADDRESS_TX_MODULE = 0xEE, - ADDRESS_HANDSET = 0xEF, ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_HANDSET = 0xEA, -- EdgeTX's official handset address + ADDRESS_RX = 0xEC, + ADDRESS_TX = 0xEE, + ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard CRSF -- Frame types FRAMETYPE_DEVICE_PING = 0x28, @@ -241,7 +242,7 @@ function CRSF:requestDeviceInfo() return end self._lastDevPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER }) + CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_HANDSET }) end --- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). @@ -253,7 +254,7 @@ function CRSF:requestElrsStatus() return end self._lastStatusPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) + CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 }) end -- ============================================================================ @@ -262,7 +263,7 @@ end -- DEVICE_INFO handler: parses and caches module name, version, RFMOD/RFRSSI local function onDeviceInfo(data) - if data[2] ~= CRSF.CONST.ADDRESS_TX_MODULE then + if data[2] ~= CRSF.CONST.ADDRESS_TX then return end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 257e115..fc53ae8 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -81,8 +81,8 @@ end -- Reload at root: switch back to TX device or reload fields + ping. function App.reloadAtRoot() - if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then - local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_TX then + local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_TX) if txDevice then App.loadDevice(txDevice) end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index c251c14..feae861 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -40,10 +40,10 @@ local Protocol = { -- Addresses ADDRESS_BROADCAST = 0x00, - ADDRESS_RADIO_TRANSMITTER = 0xEA, - ADDRESS_CRSF_RECEIVER = 0xEC, - ADDRESS_CRSF_TRANSMITTER = 0xEE, - ADDRESS_ELRS_LUA = 0xEF, + ADDRESS_HANDSET = 0xEA, -- EdgeTX's official handset address + ADDRESS_RX = 0xEC, + ADDRESS_TX = 0xEE, + ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard CRSF -- ELRS identification ELRS_SERIAL_ID = 0x454C5253, @@ -67,9 +67,9 @@ local Protocol = { -- Handlers dispatch table (populated after function definitions) handlers = {}, - -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua - deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) - handsetId = 0xEF, -- ADDRESS_ELRS_LUA + -- Device identity (used in every CRSF frame) + deviceId = 0xEE, -- ADDRESS_TX (can't self-ref before table is created) + handsetId = 0xEF, -- ADDRESS_HANDSET_ELRS deviceName = nil, deviceIsELRS_TX = nil, @@ -109,8 +109,8 @@ local Protocol = { -- ============================================================================ function Protocol.reset() - Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER - Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA + Protocol.deviceId = Protocol.CRSF.ADDRESS_TX + Protocol.handsetId = Protocol.CRSF.ADDRESS_HANDSET_ELRS Protocol.deviceName = nil Protocol.deviceIsELRS_TX = nil @@ -151,10 +151,7 @@ function Protocol.push(command, data) end function Protocol.pingDevices() - Protocol.push( - Protocol.CRSF.FRAMETYPE_DEVICE_PING, - { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER } - ) + Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_HANDSET }) end -- Check if telemetry is being received from the RX (elrsFlags bit 1) @@ -201,9 +198,8 @@ function Protocol.setDevice(device) Protocol.elrsFlags = 0 Protocol.deviceName = device.name Protocol.fieldsCount = device.fieldCount - Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA - or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_TX or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_HANDSET_ELRS or Protocol.CRSF.ADDRESS_HANDSET Protocol.allocateFields() Protocol.reloadAllFields() @@ -707,7 +703,7 @@ function Protocol.parseParameterInfoMessage(data) Protocol.fieldChunk = 0 Protocol.fieldData = nil - return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 + return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_TX or #Protocol.loadQueue == 0 end end @@ -726,7 +722,7 @@ function Protocol.parseElrsInfoMessage(data) end function Protocol.parseElrsV1Message(data) - if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then + if (data[1] ~= Protocol.CRSF.ADDRESS_HANDSET) or (data[2] ~= Protocol.CRSF.ADDRESS_TX) then return end Protocol.elrsV1Detected = true diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index f282b1f..3782e06 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -190,16 +190,13 @@ end -- ============================================================================ function Protocol.sendParameterRead(fieldId) - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_READ, - { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, 0 } - ) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, { crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET_ELRS, fieldId, 0 }) end function Protocol.sendParameterWrite(fieldId, value) crsf.push( crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, value } + { crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET_ELRS, fieldId, value } ) end @@ -213,7 +210,7 @@ end -- ============================================================================ function Protocol.onSettingsEntry(data) - if data[2] ~= crsf.CONST.ADDRESS_TX_MODULE then + if data[2] ~= crsf.CONST.ADDRESS_TX then return end From 5caa3c670c1d4e3d407de3395b196b0b7f44eb3f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 9 Jul 2026 22:51:19 +0300 Subject: [PATCH 117/218] require ExpressLRS v3.5.4 minimum --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c1b0438..a80f436 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ The package also includes two color-LCD widgets: the **ELRS Telemetry Widget** a ## Features - Configure packet rate, telemetry ratio, switch mode, model match, antenna mode, TX power, WiFi connectivity, and more -- Compatible with **ExpressLRS v3.0+** +- Compatible with **ExpressLRS v3.5.4+** ## Installation @@ -88,5 +88,5 @@ See [docs/development.md](docs/development.md) for the tool's internal architect | Radio type | Firmware | ExpressLRS | |------------|----------|------------| -| Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.0+ | -| Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.0+ | +| Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ | +| Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ | From ff04b8c1d7b38b05418db9d150279fb9f8bee95b Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 25 Jul 2026 09:55:33 -0400 Subject: [PATCH 118/218] Add X button to delete history items, fix a couple type errors --- src/SCRIPTS/TOOLS/elrs-bind.lua | 42 ++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-bind.lua b/src/SCRIPTS/TOOLS/elrs-bind.lua index 4df4c44..8ea94ec 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind.lua @@ -64,6 +64,15 @@ function History.add(s) table.remove(History.vals) end + History.save() +end + +function History.remove(idx) + table.remove(History.vals, idx) + History.save() +end + +function History.save() local f = io.open(History.FNAME, "w") if f == nil then return @@ -190,6 +199,9 @@ local function history_press(id) bindPhrase = History.vals[id] rebuildUi() end +local function history_remove(id) + History.remove(id) +end rebuildUi = function() lvgl.clear() @@ -311,30 +323,44 @@ rebuildUi = function() }) -- ***** Bind Phrase History ***** - local row = pg:box({ + local histSection = pg:box({ w = lvgl.PERCENT_SIZE + 100, y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, + flexPad = 0, -- visible if there is history and TX selected or RX selected and isConnected visible = function() - return #History.vals and isTargetReachable() + return #History.vals > 0 and isTargetReachable() end, }) - row:label({ text = "Bind Phrase History" }) + histSection:label({ text = "Bind Phrase History" }) for i = 1, History.MAX do + local row = histSection:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + visible = function() + return history_visible(i) + end, + }) + -- Button containing a history item with its value row:button({ w = lvgl.PERCENT_SIZE + 80, text = function() - return history_text(i) - end, - visible = function() - return history_visible(i) + return history_text(i) or "" end, press = function() return history_press(i) end, }) + -- Button X to delete an item + row:button({ + text = "X", + textColor = COLOR_THEME_WARNING, + press = function() + return history_remove(i) + end, + }) end end From 9d9623a8f418693225c2a99b0920abcff36c7684 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 25 Jul 2026 10:21:44 -0400 Subject: [PATCH 119/218] Add Both option to set both the RX and TX at the same time --- src/SCRIPTS/TOOLS/elrs-bind.lua | 49 +++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-bind.lua b/src/SCRIPTS/TOOLS/elrs-bind.lua index 8ea94ec..4973f60 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind.lua @@ -2,7 +2,8 @@ local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver +local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver, 3 = Both +local targetBothStep = nil -- If setting Both RX and TX, state of change local bindPhrase = "" local uidText = "" @@ -119,7 +120,11 @@ local function onMspResponse(data) end local function isTargetReachable() - return targetIdx == 1 or CRSF.isConnected + return targetIdx == 1 or (targetIdx == 2 and CRSF.isConnected) +end + +local function isTargetReachableOrBoth() + return isTargetReachable() or targetIdx == 3 end local function requestUid() @@ -148,11 +153,28 @@ local function sendBindphrase() return end - local rxTx = (targetIdx == 1) and "Transmitter" or "Receiver" - uidText = "Setting " .. rxTx .. "..." + -- BothStep state machine + if targetIdx == 3 then + if targetBothStep == nil then + -- Send to RX + targetBothStep = 2 + elseif targetBothStep == 2 then + -- Stop both, send to TX, and change the select to TX + targetBothStep = nil + targetIdx = 1 + end + end + + local effectiveTargetIdx = targetBothStep or targetIdx + if targetBothStep == nil then + local rxTx = (targetIdx == 1) and "Transmitter" or "Receiver" + uidText = "Setting " .. rxTx .. "..." + else + uidText = "Setting RX and disconnecting..." + end local data = { - (targetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX, + (effectiveTargetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER, 0x30, 0x01 + #bindPhrase, @@ -168,8 +190,13 @@ local function sendBindphrase() CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) History.add(bindPhrase) - -- refresh the UID in 1000ms - Defer.setTimeout(100, requestUid) + if targetBothStep == nil then + -- refresh the UID in 1000ms + Defer.setTimeout(100, requestUid) + else + -- Perform the next step of setBindphrase() for Both + Defer.setTimeout(100, sendBindphrase) + end end local function sendBindTx() @@ -237,14 +264,14 @@ rebuildUi = function() set = function(v) bindPhrase = v end, - active = isTargetReachable, + active = isTargetReachableOrBoth, }, { type = lvgl.BUTTON, text = "Set", press = sendBindphrase, active = function() - return isTargetReachable() and bindPhrase ~= "" + return isTargetReachableOrBoth() and bindPhrase ~= "" end, }, }, @@ -266,7 +293,7 @@ rebuildUi = function() { type = lvgl.CHOICE, title = "Select Target", - values = { "Transmitter", "Receiver" }, + values = { "Transmitter", "Receiver", "Both" }, get = function() return targetIdx end, @@ -330,7 +357,7 @@ rebuildUi = function() flexPad = 0, -- visible if there is history and TX selected or RX selected and isConnected visible = function() - return #History.vals > 0 and isTargetReachable() + return #History.vals > 0 and isTargetReachableOrBoth() end, }) histSection:label({ text = "Bind Phrase History" }) From 4c5869fdb694a1c5b52335c89beddb0e7a235f4a Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Sat, 25 Jul 2026 10:26:02 -0400 Subject: [PATCH 120/218] Move elrs-bind into its own folder for future B&W UI flexibility --- src/SCRIPTS/TOOLS/{elrs-bind.lua => elrs-bind/main.lua} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/SCRIPTS/TOOLS/{elrs-bind.lua => elrs-bind/main.lua} (99%) diff --git a/src/SCRIPTS/TOOLS/elrs-bind.lua b/src/SCRIPTS/TOOLS/elrs-bind/main.lua similarity index 99% rename from src/SCRIPTS/TOOLS/elrs-bind.lua rename to src/SCRIPTS/TOOLS/elrs-bind/main.lua index 4973f60..49d4305 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind/main.lua @@ -43,7 +43,7 @@ end local History = { MAX = 5, - FNAME = "elrs-bind.txt", + FNAME = "history.txt", vals = {}, } From 187b162b45337be185e62a59b2bfe1f2440e2ce8 Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Mon, 27 Jul 2026 18:15:34 -0400 Subject: [PATCH 121/218] Add support for direct UID input as comma-separated numbers --- src/SCRIPTS/TOOLS/elrs-bind/main.lua | 96 ++++++++++++++++++++++------ 1 file changed, 78 insertions(+), 18 deletions(-) diff --git a/src/SCRIPTS/TOOLS/elrs-bind/main.lua b/src/SCRIPTS/TOOLS/elrs-bind/main.lua index 49d4305..5fed04e 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind/main.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind/main.lua @@ -148,7 +148,81 @@ local function requestUid() Defer.setTimeout(50, requestUid) end -local function sendBindphrase() +local function isValidUidByte(s) + local n = tonumber(s) + -- Must be a number, an integer, and within 0..255 range + return n ~= nil and n == math.floor(n) and n >= 0 and n < 256 +end + +local function uidBytesFromText(text) + -- 1. If text is ONLY numbers, commas, and spaces + if string.match(text, "^[0-9, ]+$") then + local asArray = {} + + -- 2. Split by comma and filter valid bytes (trimming whitespace) + for part in string.gmatch(text, "[^,]+") do + local trimmed = string.match(part, "^%s*(.-)%s*$") + + if isValidUidByte(trimmed) then + asArray[#asArray + 1] = tonumber(trimmed) + else + return nil + end + end + + -- 3. If between 4 and 6 valid bytes, left-pad with 0s up to 6 + if #asArray >= 4 and #asArray <= 6 then + local padded = {} + local padCount = 6 - #asArray + + -- Push leading zeroes + for i = 1, padCount do + padded[#padded + 1] = 0 + end + + -- Push existing bytes + for i = 1, #asArray do + padded[#padded + 1] = asArray[i] + end + + return padded + end + end + + return nil +end + +local function sendBindinfoPacket(targetAddr) + -- Test if the string is a UID, and if so use the UID else use the string bindphrase + local uidBytes = uidBytesFromText(bindPhrase) + local mspPayloadLen = uidBytes and #uidBytes or #bindPhrase + local subcmd = uidBytes and ELRS_RXTX_SUBCMD_UID or ELRS_RXTX_SUBCMD_BIND_PHRASE + + local data = { + targetAddr, + CRSF.CONST.ADDRESS_RADIO_TRANSMITTER, + 0x30, + 0x01 + mspPayloadLen, + MSP_ELRS_RXTX_CONFIG, + subcmd, + } + + if uidBytes then + -- append the UID as bytes + for i = 1, #uidBytes do + data[#data + 1] = uidBytes[i] + end + else + -- append the phrase characters as bytes + for i = 1, #bindPhrase do + data[#data + 1] = string.byte(bindPhrase, i) + end + end + + CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) +end + +local function requestSendBindphrase() if bindPhrase == "" then return end @@ -173,21 +247,7 @@ local function sendBindphrase() uidText = "Setting RX and disconnecting..." end - local data = { - (effectiveTargetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX, - CRSF.CONST.ADDRESS_RADIO_TRANSMITTER, - 0x30, - 0x01 + #bindPhrase, - MSP_ELRS_RXTX_CONFIG, - ELRS_RXTX_SUBCMD_BIND_PHRASE, - } - - -- append the phrase as bytes - for i = 1, #bindPhrase do - data[#data + 1] = string.byte(bindPhrase, i) - end - - CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) + sendBindinfoPacket((effectiveTargetIdx == 1) and CRSF.CONST.ADDRESS_TX_MODULE or CRSF.CONST.ADDRESS_RX) History.add(bindPhrase) if targetBothStep == nil then @@ -195,7 +255,7 @@ local function sendBindphrase() Defer.setTimeout(100, requestUid) else -- Perform the next step of setBindphrase() for Both - Defer.setTimeout(100, sendBindphrase) + Defer.setTimeout(100, requestSendBindphrase) end end @@ -269,7 +329,7 @@ rebuildUi = function() { type = lvgl.BUTTON, text = "Set", - press = sendBindphrase, + press = requestSendBindphrase, active = function() return isTargetReachableOrBoth() and bindPhrase ~= "" end, From fd2c6bb74df63ec0a5d2651c0094ee792a2d100e Mon Sep 17 00:00:00 2001 From: Bryan Mayland Date: Mon, 27 Jul 2026 18:27:01 -0400 Subject: [PATCH 122/218] Center history label since adding the delete shifted it to the edge --- src/SCRIPTS/TOOLS/elrs-bind/main.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/elrs-bind/main.lua b/src/SCRIPTS/TOOLS/elrs-bind/main.lua index 5fed04e..4b9de1a 100644 --- a/src/SCRIPTS/TOOLS/elrs-bind/main.lua +++ b/src/SCRIPTS/TOOLS/elrs-bind/main.lua @@ -420,7 +420,11 @@ rebuildUi = function() return #History.vals > 0 and isTargetReachableOrBoth() end, }) - histSection:label({ text = "Bind Phrase History" }) + histSection:label({ + text = "Bind Phrase History", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + }) for i = 1, History.MAX do local row = histSection:box({ w = lvgl.PERCENT_SIZE + 100, From 0e93851adc1421928e66afbc49c99bc264c74218 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 9 Aug 2026 22:30:10 +0300 Subject: [PATCH 123/218] stop polling ELRS status from the VTX Admin widget The widget requested ELRS_STATUS every 2 seconds by writing field 0, but nothing under WIDGETS/ELRSVTXAdmin ever read the resulting hasTelemetry, modelMismatch or elrsFlagsInfo state. The UI is driven entirely by the folder PARAMETER_READ sent alongside it. On the TX side that write only triggers an ELRS_STATUS reply; it sets no mode, arms no timer and raises no device event, so dropping it changes no behaviour and halves the widget's steady-state traffic. It also bypassed the CRSF singleton's rate limiter, so with the Telemetry widget loaded the two sent 1.5 status writes per second instead of 1. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 3782e06..576fa6a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -200,11 +200,6 @@ function Protocol.sendParameterWrite(fieldId, value) ) end ---- Request ELRS_STATUS (connection state) by writing field 0. -function Protocol.sendStatusPoll() - Protocol.sendParameterWrite(0, 0) -end - -- ============================================================================ -- Protocol: Response handler (registered on singleton dispatcher) -- ============================================================================ @@ -315,7 +310,6 @@ function Protocol.tick() if now - Protocol.lastFolderPoll >= Protocol.FOLDER_POLL_INTERVAL then Protocol.lastFolderPoll = now Protocol.sendParameterRead(VTX.ids.folder) - Protocol.sendStatusPoll() end elseif st == Protocol.STATE_SENDING then if Protocol.writeIdx <= #Protocol.writeQueue then From e05809cac86a6d65993df9c0a6e96d7d6e2e4b64 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 00:49:48 +0300 Subject: [PATCH 124/218] give VTX Admin one home for state checks and display strings The five per-screen UI files each re-derived the same things locally: "not Protocol.isActive() or VTX.state.band == 0" appeared on 15 lines under ui/ and 8 more in loadable.lua, pitModeColor and pitModeText were defined identically in sd_tall, portrait and small, and the power string existed in four places. Add Protocol.hasModule() and VTX.isTuned() for the state checks, and move every power and pit-mode string into VTXDisplay as powerShort, powerLong, pitText, pitTextLong, pitShort and pitColor. detailLine and detailLong now compose those rather than rebuilding them. The screen files drop their local copies and reference the shared ones directly, which works because LVGL takes them as bare zero-arg callbacks. Protocol.hasModule() also names the condition the 6POS cheatsheet was already using inline: presets come from presets.txt on the radio, not from the VTX, so they are worth showing before discovery finishes. That is why it is weaker than isActive(), which was not obvious from the open-coded comparison portrait had given a local name. No behaviour change: statusText keeps its three-way branch so the loading state survives, detailLong keeps returning "VTX Disabled" on band 0, and pitShort and pitColor keep their guards. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 88 +++++++++++++++++------- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 55 +++++---------- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 18 +---- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 43 ++---------- 4 files changed, 91 insertions(+), 113 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 576fa6a..b51a11f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -84,6 +84,11 @@ function VTX.parseFolderName(name) return true end +--- True when the VTX is tuned to a band. +function VTX.isTuned() + return Protocol.isActive() and VTX.state.band > 0 +end + --- Sync desired values with current state (e.g. on discovery or entering full-screen). function VTX.syncDesiredFromState() local s = VTX.state @@ -142,6 +147,13 @@ function Protocol.isActive() return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING end +--- True when a CRSF module answered discovery. Weaker than isActive(): the 6POS preset +--- cheatsheet is local radio state read from presets.txt, not VTX telemetry, so it is worth +--- showing before discovery finishes. +function Protocol.hasModule() + return Protocol.state ~= Protocol.STATE_NO_MODULE +end + -- Response timeout for PARAMETER_READ: 0.5s for local TX module. function Protocol.fieldResponseTimeout() return 50 @@ -713,17 +725,17 @@ local VTXDisplay = {} --- True when VTX is tuned to a band (band+channel should be shown in fixed column). function VTXDisplay.showChannel() - return Protocol.isActive() and VTX.state.band > 0 + return VTX.isTuned() end --- True when a status message should be shown (loading, error, VTX off). function VTXDisplay.showStatus() - return not Protocol.isActive() or VTX.state.band == 0 + return not VTX.isTuned() end --- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise. function VTXDisplay.bandChannel() - if not Protocol.isActive() or VTX.state.band == 0 then + if not VTX.isTuned() then return "" end return table.concat({ VTX.state.bandLetter, VTX.state.channel }) @@ -731,7 +743,7 @@ end --- Short status message for non-VTX states, "" when VTX is tuned. function VTXDisplay.statusText() - if Protocol.state == Protocol.STATE_NO_MODULE then + if not Protocol.hasModule() then return "No module" end if not Protocol.isActive() then @@ -743,35 +755,65 @@ function VTXDisplay.statusText() return "" end -function VTXDisplay.detailLine() - if not Protocol.isActive() then +function VTXDisplay.powerShort() + if not VTX.isTuned() then return "" end - if VTX.state.band == 0 then + return VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" +end + +function VTXDisplay.powerLong() + if not VTX.isTuned() then return "" end - local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" - local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" - return table.concat({ pwr, pit }) + return VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" end -function VTXDisplay.powerShort() - if not Protocol.isActive() or VTX.state.band == 0 then +function VTXDisplay.pitText() + if not VTX.isTuned() then return "" end - return VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" end -function VTXDisplay.detailLong() - if not Protocol.isActive() then +--- As pitText, but reports a disabled VTX instead of falling silent. +function VTXDisplay.pitTextLong() + if Protocol.isActive() and VTX.state.band == 0 then + return "VTX Disabled" + end + return VTXDisplay.pitText() +end + +--- Terse flag for narrow tiers. +function VTXDisplay.pitShort() + if not VTX.isTuned() then return "" end - if VTX.state.band == 0 then + return VTX.state.pitmode and "Pit" or "" +end + +function VTXDisplay.pitColor() + if not VTX.isTuned() then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +function VTXDisplay.detailLine() + if not VTX.isTuned() then + return "" + end + return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() }) +end + +function VTXDisplay.detailLong() + if Protocol.isActive() and VTX.state.band == 0 then return "VTX Disabled" end - local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" - local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" - return table.concat({ pwr, pit }) + if not VTX.isTuned() then + return "" + end + return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) end function VTXDisplay.mainColor() @@ -782,7 +824,7 @@ function VTXDisplay.mainColor() end function VTXDisplay.build6posLabels() - if Protocol.state == Protocol.STATE_NO_MODULE then + if not Protocol.hasModule() then return {} end if not Presets.enabled then @@ -821,9 +863,7 @@ function VTXDisplay.buildCheatsheet() borderPad = 0, flexPad = lvgl.PAD_TINY, align = LEFT, - visible = function() - return Protocol.state ~= Protocol.STATE_NO_MODULE - end, + visible = Protocol.hasModule, children = labels, } end @@ -1007,7 +1047,7 @@ local function buildFullScreen() }) -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) - if Protocol.state == Protocol.STATE_NO_MODULE then + if not Protocol.hasModule() then pg:rectangle({ w = lvgl.PERCENT_SIZE + 100, thickness = 0, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 9ab7d9f..3334580 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -31,35 +31,29 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, detail = SMLSIZE }, } -local function pitModeColor() - if not Protocol.isActive() or VTX.state.band == 0 then - return COLOR_THEME_SECONDARY1 - end - return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 -end - -local function pitModeText() - if not Protocol.isActive() or VTX.state.band == 0 then - return "" - end - return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" -end - -- ============================================================================ -- Minimized display helpers (portrait-specific overrides) -- ============================================================================ --- Shorter detail line for narrow portrait screen. local function detailLine() - if not Protocol.isActive() then + if not VTX.isTuned() then return "" end - if VTX.state.band == 0 then + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({ VTXDisplay.powerShort(), pit }) +end + +--- Shorter long-form detail line for narrow portrait screen. +local function detailLong() + if Protocol.isActive() and VTX.state.band == 0 then + return "VTX Disabled" + end + if not VTX.isTuned() then return "" end - local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" - local pit = VTX.state.pitmode and " Pit" or "" - return table.concat({ pwr, pit }) + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({ VTXDisplay.powerLong(), pit }) end --- Build two narrow cheatsheet rows (3 labels each), or nil pair. @@ -75,16 +69,13 @@ local function buildCheatsheetNarrow() for i = 4, 6 do row2[#row2 + 1] = labels[i] end - local hasModule = function() - return Protocol.state ~= Protocol.STATE_NO_MODULE - end return { type = lvgl.BOX, align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, borderPad = 0, - visible = hasModule, + visible = Protocol.hasModule, children = row1, }, { type = lvgl.BOX, @@ -92,7 +83,7 @@ local function buildCheatsheetNarrow() flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, borderPad = 0, - visible = hasModule, + visible = Protocol.hasModule, children = row2, } end @@ -190,8 +181,8 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = pitModeColor, - text = pitModeText, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitText, }, }, }, @@ -308,17 +299,7 @@ function WidgetUI.buildHalf(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = function() - if not Protocol.isActive() then - return "" - end - if VTX.state.band == 0 then - return "VTX Disabled" - end - local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" - local pit = VTX.state.pitmode and " Pit" or "" - return table.concat({ pwr, pit }) - end, + text = detailLong, }, } if w < 200 then diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index f3e5084..70e16d6 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -30,20 +30,6 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, detail = SMLSIZE }, } -local function pitModeColor() - if not Protocol.isActive() or VTX.state.band == 0 then - return COLOR_THEME_SECONDARY1 - end - return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 -end - -local function pitModeText() - if not Protocol.isActive() or VTX.state.band == 0 then - return "" - end - return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -120,8 +106,8 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = pitModeColor, - text = pitModeText, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitText, } end local rows = { diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 6e28899..d8da344 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -30,30 +30,6 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, detail = SMLSIZE }, } -local function pitModeColor() - if not Protocol.isActive() or VTX.state.band == 0 then - return COLOR_THEME_SECONDARY1 - end - return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 -end - -local function pitModeText() - if not Protocol.isActive() or VTX.state.band == 0 then - return "" - end - return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" -end - -local function pitModeTextLong() - if not Protocol.isActive() then - return "" - end - if VTX.state.band == 0 then - return "VTX Disabled" - end - return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -95,8 +71,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = pitModeColor, - text = pitModeText, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitText, }, } local labels = VTXDisplay.build6posLabels() @@ -150,12 +126,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = RED, - text = function() - if not Protocol.isActive() or VTX.state.band == 0 then - return "" - end - return VTX.state.pitmode and "Pit" or "" - end, + text = VTXDisplay.pitShort, }, }, }, @@ -211,8 +182,8 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = pitModeColor, - text = pitModeText, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitText, }, }, } @@ -268,8 +239,8 @@ function WidgetUI.buildHalf(w, h, opa) type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = pitModeColor, - text = pitModeTextLong, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitTextLong, }, }, }, From 3bad52ac4e778ce298b9ec1b50dc4d87e4cc9be7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 00:50:31 +0300 Subject: [PATCH 125/218] fix aux-switch pit mode always reading as off VTX.parseFolderName tested parts[#parts] == "P", but the firmware only writes "P" when Pitmode is set to On. When it is bound to a switch, updateFolderName_VtxAdmin writes the aux label instead - "AUX1\192" through "AUX10\193", where \192 and \193 are the ExpressLRS up and down arrow glyphs. Every switch-bound configuration therefore displayed "Pit Mode Off". Presence of a fourth field, not its text, is what marks pit mode configured. Parse it as three states: "P" is confirmed on, anything else is a binding whose switch name is the label minus its one trailing arrow byte, and no fourth field is off. string.sub does that without a pattern match, which matters on a path that runs per folder read. pitmode now means "definitely on" and gains a pitmodeAux companion, so a binding renders "Pit Mode AUX1" and leaves the display its normal colour rather than turning red. Red asserts the VTX is muted, and the folder name carries only the binding, never the live switch position - the firmware derives that from the channel data, which is not worth a pattern match here for a value the name never promised. The CRSF simulator's Pitmode parameter only offered "Off;On" and so could not reach this path at all; it now carries the firmware's real option list. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 13 ++++++++- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 31 ++++++++++++++++++--- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index fd3d702..e219b2d 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -391,6 +391,17 @@ end -- Matches TXModuleParameters.cpp parameter structure -- ============================================================================ +--- STR_LUA_ALLAUX_UPDOWN from CRSFParameters.h: "AUX1;AUX1;...;AUX10", +--- where \192 and \193 are the ExpressLRS up/down arrow glyphs. +local ALLAUX_UPDOWN = (function() + local opts = {} + for i = 1, 10 do + opts[#opts + 1] = table.concat({ "AUX", i, "\192" }) + opts[#opts + 1] = table.concat({ "AUX", i, "\193" }) + end + return table.concat(opts, ";") +end)() + local txDevice = { id = CRSF.ADDRESS_TX, name = "TX16S MK3", @@ -501,7 +512,7 @@ local txDevice = { parent = 10, type = CRSF.TEXT_SELECTION, name = "Pitmode", - options = "Off;On", + options = table.concat({ "Off;On;", ALLAUX_UPDOWN }), value = 0, units = "", }, diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index b51a11f..c9aa384 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -42,7 +42,8 @@ VTX = { bandLetter = "?", channel = 0, power = 0, - pitmode = false, + pitmode = false, -- true only when pit mode is confirmed on + pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch }, -- Desired VTX state (edited by user in full-screen UI) @@ -55,7 +56,11 @@ VTX = { } --- Parse "VTX Admin (R:4:2:P)" into VTX.state fields. --- When band is Off, folder name has no dynamic suffix. +-- ExpressLRS writes "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])": band Off drops the whole +-- suffix, power "-" drops both power and pit mode, pit mode Off drops itself. PITMODE is "P" +-- when set to On, or the aux label ("AUX1\192".."AUX10\193", \192/\193 = up/down arrow) when +-- bound to a switch. The name carries only the binding, never the switch position, so an aux +-- binding sets pitmodeAux and leaves pitmode false. function VTX.parseFolderName(name) local s = VTX.state local content = string.match(name, "%((.+)%)") @@ -65,6 +70,7 @@ function VTX.parseFolderName(name) s.channel = 0 s.power = 0 s.pitmode = false + s.pitmodeAux = nil return true end @@ -80,7 +86,16 @@ function VTX.parseFolderName(name) s.band = VTX.BAND_VALUES[parts[1]] or 0 s.channel = tonumber(parts[2]) or 0 s.power = tonumber(parts[3]) or 0 - s.pitmode = (parts[#parts] == "P") + if #parts < 4 then + s.pitmode = false + s.pitmodeAux = nil + elseif parts[4] == "P" then + s.pitmode = true + s.pitmodeAux = nil + else + s.pitmode = false + s.pitmodeAux = string.sub(parts[4], 1, -2) + end return true end @@ -769,11 +784,19 @@ function VTXDisplay.powerLong() return VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" end +--- A switch binding names the switch rather than asserting a position the folder name +--- does not carry. function VTXDisplay.pitText() if not VTX.isTuned() then return "" end - return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" + if VTX.state.pitmode then + return "Pit Mode On" + end + if VTX.state.pitmodeAux then + return table.concat({ "Pit Mode ", VTX.state.pitmodeAux }) + end + return "Pit Mode Off" end --- As pitText, but reports a disabled VTX instead of falling silent. From 9a0681afb972c94126a342c4a7029b560ff52fa2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 00:51:33 +0300 Subject: [PATCH 126/218] hide VTX power and pit mode when no power level is set ExpressLRS treats power level 0 as "no change" - luaVtxPwr's options are literally "-;1;2;3;4;5;6;7;8". updateFolderName_VtxAdmin omits both power and pit mode from the folder name in that case, and the module hides the Pitmode field outright: // Pit mode can only be sent as part of the power byte LUA_FIELD_VISIBLE(luaVtxPit, config.GetVtxPower() != 0); The widget showed "P-", "Power -" and "Pit Mode Off" anyway, which claims a pit mode state the protocol has no way to hold. Add VTX.hasPower() and gate every power and pit string on it, so the line is simply absent until a level is chosen, and hide the Pit Mode row in the full-screen editor to match. The row keys on the desired value, so it appears as soon as the user picks a level rather than waiting for a write to land. The "P-" and "Power -" fallbacks are then unreachable and are gone, which is what the redundant-generator review comment was really about. pitShort and pitColor lose their guards as redundant: after the previous commit pitmode can only be true when tuned and powered. VTX.isDisabled() names the remaining band-0 check that detailLong and pitTextLong need, since those report "VTX Disabled" rather than falling silent. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 50 +++++++++++++++--------- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 6 +-- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index c9aa384..ee0e570 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -104,6 +104,18 @@ function VTX.isTuned() return Protocol.isActive() and VTX.state.band > 0 end +--- True when the module is up but the VTX band is set to Off. +function VTX.isDisabled() + return Protocol.isActive() and VTX.state.band == 0 +end + +--- True when a power level is set. ExpressLRS omits power and pit mode from the VTX Admin +--- folder name when power is "-", and hides the Pitmode field entirely, so neither value is +--- meaningful until a power level is chosen. +function VTX.hasPower() + return VTX.isTuned() and VTX.state.power > 0 +end + --- Sync desired values with current state (e.g. on discovery or entering full-screen). function VTX.syncDesiredFromState() local s = VTX.state @@ -771,23 +783,24 @@ function VTXDisplay.statusText() end function VTXDisplay.powerShort() - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end - return VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + return table.concat({ "P", VTX.state.power }) end function VTXDisplay.powerLong() - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end - return VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" + return table.concat({ "Power ", VTX.state.power }) end +--- Pit mode state. "" when no power is set: ExpressLRS cannot send pit mode without it. --- A switch binding names the switch rather than asserting a position the folder name --- does not carry. function VTXDisplay.pitText() - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end if VTX.state.pitmode then @@ -801,39 +814,34 @@ end --- As pitText, but reports a disabled VTX instead of falling silent. function VTXDisplay.pitTextLong() - if Protocol.isActive() and VTX.state.band == 0 then + if VTX.isDisabled() then return "VTX Disabled" end return VTXDisplay.pitText() end ---- Terse flag for narrow tiers. +--- Terse flag for narrow tiers. Only a confirmed pit mode is worth the width. function VTXDisplay.pitShort() - if not VTX.isTuned() then - return "" - end return VTX.state.pitmode and "Pit" or "" end +--- Red only when pit mode is confirmed on. An aux binding is not an assertion. function VTXDisplay.pitColor() - if not VTX.isTuned() then - return COLOR_THEME_SECONDARY1 - end return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 end function VTXDisplay.detailLine() - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() }) end function VTXDisplay.detailLong() - if Protocol.isActive() and VTX.state.band == 0 then + if VTX.isDisabled() then return "VTX Disabled" end - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) @@ -936,12 +944,13 @@ local WidgetUI = loadScript(uiPath)({ -- Portrait screens get a narrower label column to leave more room for controls. local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 -local function createRow(container, label, hint) +local function createRow(container, label, hint, visibleFn) local row = container:rectangle({ w = lvgl.PERCENT_SIZE + 100, thickness = 0, flexFlow = lvgl.FLOW_ROW, flexPad = 0, + visible = visibleFn, }) local labelChildren = { @@ -997,8 +1006,8 @@ local function createNumberRow(container, label, min, max, getFn, setFn, editedF }) end -local function createToggleRow(container, label, getFn, setFn) - local ctrl = createRow(container, label) +local function createToggleRow(container, label, getFn, setFn, visibleFn) + local ctrl = createRow(container, label, nil, visibleFn) ctrl:toggle({ get = getFn, set = setFn, @@ -1126,11 +1135,14 @@ local function buildFullScreen() return v == 0 and "-" or tostring(v) end) + -- Pit mode rides on the power byte, so ExpressLRS hides it while power is "-". createToggleRow(fields, "Pit Mode", function() return d.pitmode end, function(v) d.pitmode = v Protocol.writeConfig() + end, function() + return d.power > 0 end) local sendWrapper = fields:box({ diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 3334580..868bb4b 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -37,7 +37,7 @@ WidgetUI.fonts = { --- Shorter detail line for narrow portrait screen. local function detailLine() - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end local pit = VTX.state.pitmode and " Pit" or "" @@ -46,10 +46,10 @@ end --- Shorter long-form detail line for narrow portrait screen. local function detailLong() - if Protocol.isActive() and VTX.state.band == 0 then + if VTX.isDisabled() then return "VTX Disabled" end - if not VTX.isTuned() then + if not VTX.hasPower() then return "" end local pit = VTX.state.pitmode and " Pit" or "" From 538d98410df109ecf35a8b05f3422b6ea9012535 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 00:52:11 +0300 Subject: [PATCH 127/218] rebuild the VTX Admin top bar as label over value The top bar rendered one MIDSIZE line, "R2 P2", built from a local getStatusLine() that duplicated VTXDisplay.bandChannel() and a second copy of the power string. It also looked nothing like the status bar widgets beside it. Stack it instead: "VTX" over the band and channel, two centred SMLSIZE lines. That matches EdgeTX's own HeaderDateTime, which is the stock element with this shape, and the sibling ELRSTelemetry top bar. It is deliberately not ValueWidget, whose FONT(STD) caption over FONT(L) value comes to 43px of text in a 39px top-bar zone and clips the descenders - two SMLSIZE lines are 34px in the same space. Power no longer appears here at all, so the line does not recentre when a level is set or cleared; the larger tiers still carry it. Dropping both local helpers leaves the file needing only VTXDisplay, so the context table the five screen files pass in shrinks to that one key. --- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 3 +- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 3 +- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 3 +- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 3 +- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 3 +- src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua | 38 ++++++++++-------------- 6 files changed, 21 insertions(+), 32 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 442fea4..aaca72e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -37,8 +37,7 @@ WidgetUI.fonts = { -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, - VTX = VTX, + VTXDisplay = VTXDisplay, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 868bb4b..8f6e6ff 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -93,8 +93,7 @@ end -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, - VTX = VTX, + VTXDisplay = VTXDisplay, }) --- 1/6: single row with band/channel + compact detail. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index c4714e0..e17129e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -34,8 +34,7 @@ WidgetUI.fonts = { -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, - VTX = VTX, + VTXDisplay = VTXDisplay, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 70e16d6..aeee0fc 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -35,8 +35,7 @@ WidgetUI.fonts = { -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, - VTX = VTX, + VTXDisplay = VTXDisplay, }) --- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index d8da344..923ea9b 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -35,8 +35,7 @@ WidgetUI.fonts = { -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ - Protocol = Protocol, - VTX = VTX, + VTXDisplay = VTXDisplay, }) --- 1/6: single row with band + status + power + pit mode + cheatsheet. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua index 99366ea..58816ec 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -4,22 +4,11 @@ --------------------------------------------------------------------------- local ctx = ... -local Protocol = ctx.Protocol -local VTX = ctx.VTX +local VTXDisplay = ctx.VTXDisplay local TopBarUI = {} -local function getStatusLine() - if not Protocol.isActive() then - return "--" - end - if VTX.state.band == 0 then - return "--" - end - return table.concat({ VTX.state.bandLetter, VTX.state.channel }) -end - ---- Top bar: ultra-compact single line, no background. +--- Top bar: label over value, matching EdgeTX's stock status bar widgets. function TopBarUI.build(w, h) lvgl.build({ { @@ -28,22 +17,27 @@ function TopBarUI.build(w, h) y = 0, w = w, h = h, - align = CENTER + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, + align = CENTER, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, children = { { type = lvgl.LABEL, align = CENTER, - font = MIDSIZE, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY2, + text = "VTX", + }, + { + type = lvgl.LABEL, + align = CENTER, + font = SMLSIZE, color = COLOR_THEME_PRIMARY2, text = function() - local s = getStatusLine() - if s == "--" then - return s + if VTXDisplay.showStatus() then + return "--" end - local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" - return table.concat({ s, pwr }, " ") + return VTXDisplay.bandChannel() end, }, }, From a15f1d9a8ff69521367e07bc9ce662599b7d8f54 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 00:52:50 +0300 Subject: [PATCH 128/218] show the 6POS cheatsheet as two rows Hiding the power and pit mode line frees a row in the minimized tiers, so the preset cheatsheet no longer has to fit six labels across one line in a small font. Split it 1-3 over 4-6 and let each screen pick the font per tier through WidgetUI.fonts..cheatsheet, alongside the hero and detail entries already there. The split is unconditional rather than tied to whether power is set, so the cheatsheet does not reflow every time the pilot changes power. portrait already had exactly this layout as a local buildCheatsheetNarrow for zones under 200px; promote it to VTXDisplay.buildCheatsheetRows and drop the copy. buildCheatsheet stays for the sixth and quarter tiers, which have no spare row. portrait's quarter tier keeps its existing width test, so that one still picks between the two by zone width. Fonts are STDSIZE where there is room and SMLSIZE where there is not - 480x272 at the third tier, and every tier on 320x240. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 36 ++++++++-- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 27 +++---- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 91 +++++------------------- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 27 +++---- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 27 +++---- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 27 +++---- 6 files changed, 108 insertions(+), 127 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index ee0e570..154d9a0 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -854,7 +854,7 @@ function VTXDisplay.mainColor() return COLOR_THEME_PRIMARY1 end -function VTXDisplay.build6posLabels() +function VTXDisplay.build6posLabels(font) if not Protocol.hasModule() then return {} end @@ -866,7 +866,7 @@ function VTXDisplay.build6posLabels() local idx = i labels[#labels + 1] = { type = lvgl.LABEL, - font = SMLSIZE, + font = font or SMLSIZE, color = function() return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED end, @@ -883,11 +883,8 @@ function VTXDisplay.build6posLabels() return labels end -function VTXDisplay.buildCheatsheet() - local labels = VTXDisplay.build6posLabels() - if #labels == 0 then - return nil - end +--- Wrap 6POS labels in a row box. Shared by the one-row and two-row cheatsheets. +local function cheatsheetRow(labels) return { type = lvgl.BOX, flexFlow = lvgl.FLOW_ROW, @@ -899,6 +896,31 @@ function VTXDisplay.buildCheatsheet() } end +--- Single row of all six presets, for tiers with only one line to spare. +function VTXDisplay.buildCheatsheet(font) + local labels = VTXDisplay.build6posLabels(font) + if #labels == 0 then + return nil + end + return cheatsheetRow(labels) +end + +--- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows. +function VTXDisplay.buildCheatsheetRows(font) + local labels = VTXDisplay.build6posLabels(font) + if #labels == 0 then + return nil, nil + end + local row1, row2 = {}, {} + for i = 1, 3 do + row1[#row1 + 1] = labels[i] + end + for i = 4, 6 do + row2[#row2 + 1] = labels[i] + end + return cheatsheetRow(row1), cheatsheetRow(row2) +end + -- ============================================================================ -- Screen detection and UI loading -- ============================================================================ diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index aaca72e..78a7e4a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -27,9 +27,9 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = MIDSIZE }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = 0 }, + third = { status = MIDSIZE, cheatsheet = STDSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = MIDSIZE, detail = 0, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -174,9 +174,10 @@ function WidgetUI.buildThird(w, h, opa) }, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -215,9 +216,10 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -256,9 +258,10 @@ function WidgetUI.buildFull(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 8f6e6ff..fbdc960 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -26,9 +26,9 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD, cheatsheet = STDSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -56,38 +56,6 @@ local function detailLong() return table.concat({ VTXDisplay.powerLong(), pit }) end ---- Build two narrow cheatsheet rows (3 labels each), or nil pair. -local function buildCheatsheetNarrow() - local labels = VTXDisplay.build6posLabels() - if #labels == 0 then - return nil, nil - end - local row1, row2 = {}, {} - for i = 1, 3 do - row1[#row1 + 1] = labels[i] - end - for i = 4, 6 do - row2[#row2 + 1] = labels[i] - end - return { - type = lvgl.BOX, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = Protocol.hasModule, - children = row1, - }, { - type = lvgl.BOX, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = Protocol.hasModule, - children = row2, - } -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -187,10 +155,10 @@ function WidgetUI.buildQuarter(w, h, opa) }, } if w < 200 then - local r1, r2 = buildCheatsheetNarrow() - if r1 then - rows[#rows + 1] = r1 - rows[#rows + 1] = r2 + local cs1, cs2 = VTXDisplay.buildCheatsheetRows() + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end else local cs = VTXDisplay.buildCheatsheet() @@ -252,17 +220,10 @@ function WidgetUI.buildThird(w, h, opa) }, } -- Cheatsheet rows - if w < 200 then - local r1, r2 = buildCheatsheetNarrow() - if r1 then - rows[#rows + 1] = r1 - rows[#rows + 1] = r2 - end - else - local cs = VTXDisplay.buildCheatsheet() - if cs then - rows[#rows + 1] = cs - end + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -301,17 +262,10 @@ function WidgetUI.buildHalf(w, h, opa) text = detailLong, }, } - if w < 200 then - local r1, r2 = buildCheatsheetNarrow() - if r1 then - rows[#rows + 1] = r1 - rows[#rows + 1] = r2 - end - else - local cs = VTXDisplay.buildCheatsheet() - if cs then - rows[#rows + 1] = cs - end + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -350,17 +304,10 @@ function WidgetUI.buildFull(w, h, opa) text = VTXDisplay.detailLong, }, } - if w < 200 then - local r1, r2 = buildCheatsheetNarrow() - if r1 then - rows[#rows + 1] = r1 - rows[#rows + 1] = r2 - end - else - local cs = VTXDisplay.buildCheatsheet() - if cs then - rows[#rows + 1] = cs - end + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index e17129e..3162e40 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -24,9 +24,9 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD, cheatsheet = SMLSIZE }, + half = { hero = BOLD, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -171,9 +171,10 @@ function WidgetUI.buildThird(w, h, opa) }, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -212,9 +213,10 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -253,9 +255,10 @@ function WidgetUI.buildFull(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index aeee0fc..f1b1899 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -25,9 +25,9 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD, cheatsheet = SMLSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -184,9 +184,10 @@ function WidgetUI.buildThird(w, h, opa) }, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -225,9 +226,10 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -266,9 +268,10 @@ function WidgetUI.buildFull(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 923ea9b..89afc73 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -25,9 +25,9 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, - third = { status = BOLD }, - half = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { status = BOLD, cheatsheet = SMLSIZE }, + half = { hero = BOLD, detail = SMLSIZE, cheatsheet = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = SMLSIZE }, } -- ============================================================================ @@ -186,9 +186,10 @@ function WidgetUI.buildThird(w, h, opa) }, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -244,9 +245,10 @@ function WidgetUI.buildHalf(w, h, opa) }, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) @@ -285,9 +287,10 @@ function WidgetUI.buildFull(w, h, opa) text = VTXDisplay.detailLong, }, } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 end WidgetLayout.column(w, h, opa, rows) From 7229a5c4a8e9a6220928a08a6a82a7c339dcf92b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:54:29 +0300 Subject: [PATCH 129/218] assert the 6POS position to the module on create Presets.process() latched Presets.lastPos before calling Protocol.writeConfig(), which refuses to run outside STATE_READY. At widget create lastPos is -1, so the first debounced sample was consumed by the latch and its write discarded; every later tick then returned early on pos == lastPos. Nothing reached the module until the pilot physically moved the switch, so the VTX kept whatever band and channel were last sent to it. Whether that showed depended on a race. Discovery is round-trip limited, not timeout limited - onSettingsEntry resets fieldTimeout to 0 on every response, so the 500ms in fieldResponseTimeout() is only a lost-frame retry. Roughly sixteen reads can finish inside the 200ms debounce, which is why the original report did not reproduce on every radio. The same latch can also drop a switch move made during STATE_SENDING, though that window is narrower than the debounce and could not be reproduced. Hold the position until a write can land instead. Since lastPos is -1 on every create and is never persisted, the first tick after STATE_READY becomes the boot write, through the same path as any switch move; writeConfig()'s per-field diff means nothing is sent when the module already agrees. wgt.background() already runs poll, tick then process in that order, so syncDesiredFromState() on the READY transition lands before the preset is applied rather than wiping it. Nothing is needed for programmable 6POS radios. DEFAULT_FS_STARTUP_CONFIG is FS_START_PREVIOUS on every switch and setFSStartupPosition() leaves those alone, so a customizable-switch group restores its previous position across a reboot by itself. Verified under the CRSF simulator's slow_loading scenario: before, every widget instance logged "skipped - not ready" and no write reached the module; after, none do and the module follows the switch. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 154d9a0..556821a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -635,6 +635,15 @@ function Presets.process() return end + -- Only consume a position once a write can actually land. writeConfig() drops + -- everything outside STATE_READY, and lastPos is latched before it is called, so + -- latching any earlier discards the position permanently -- during a send, and during + -- discovery whenever it outruns the debounce. Holding until ready is also what makes + -- the first tick after discovery assert the boot position to the module. + if not Protocol.isReady() then + return + end + -- Edge-triggered: only send on position change if pos == Presets.lastPos then return From 217238d87d5c2d3ba9033bc398f60c49e85dd168 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:54:39 +0300 Subject: [PATCH 130/218] stop 6POS changes writing stale power and pit mode VTX.parseFolderName zeroes power and pitmode whenever ExpressLRS omits them from the folder name, which it does whenever power is "-". syncDesiredFromState copies those zeros into VTX.desired, and nothing re-syncs desired after a send completes. A later 6POS change then diffed the stale zeros against the module's real state and wrote power 0 and pit mode off, silently undoing both. Re-base desired on the module's current state before applying the preset so the 6POS path can only ever write band and channel. Asserting the position on create makes this reachable in one switch move rather than two, so it belongs with that change. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 556821a..607d001 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -652,6 +652,10 @@ function Presets.process() local preset = Presets.items[pos] if preset and preset.band > 0 then + -- Re-base on the module's current state so a 6POS change only ever writes band and + -- channel. VTX.desired can hold stale power/pitmode: ExpressLRS omits both from the + -- folder name when power is "-", and nothing re-syncs after a send completes. + VTX.syncDesiredFromState() VTX.desired.band = preset.band VTX.desired.channel = preset.channel print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel })) From 38b2d7078e108d48313d0757f92ddf8c3abc1fb6 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:54:58 +0300 Subject: [PATCH 131/218] show unused 6POS preset slots as "--" Band 0 rendered as "Off" in the preset editor and the cheatsheet, the same word the VTX Settings band selector uses for a disabled VTX. They mean different things: one turns the VTX off, the other is an empty preset slot that leaves the VTX alone. Show "--" for the preset slot in both places and leave the VTX Settings row alone, since "Off" is right there and matches VTXDisplay's "VTX Off" and "VTX Disabled". The two choice lists now differ only in their first entry, so they stay separate literals rather than being generated from one table with the label parameterised. BAND_NAMES existed only to map band 0 to that shared "Off", so it becomes BAND_LETTERS, a plain 1-based array, and the mixed_table selene pragma goes with the [0] entry. build6posLabels tests the numeric sentinel instead of comparing a rendered string. BAND_VALUES stays as it is: it parses protocol letters, it is not a UI list. "1:--" is four characters against "1:Off"'s five, so no tier reflows. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 607d001..a4c81aa 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -21,9 +21,9 @@ local Presets -- ============================================================================ VTX = { - -- Band name lookup tables - -- selene: allow(mixed_table) - BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, + -- Band lookup tables. BAND_LETTERS is 1-based: band 0 has no letter, and its label + -- differs by context ("Off" for a disabled VTX, "--" for an unused 6POS preset slot). + BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, -- Field IDs (discovered at runtime) @@ -885,11 +885,10 @@ function VTXDisplay.build6posLabels(font) end, text = function() local p = Presets.items[idx] - local band = VTX.BAND_NAMES[p.band] or "?" - if band == "Off" then - return table.concat({ idx, ":Off" }) + if p.band == 0 then + return table.concat({ idx, ":--" }) end - return table.concat({ idx, ":", band, p.channel }) + return table.concat({ idx, ":", VTX.BAND_LETTERS[p.band] or "?", p.channel }) end, } end @@ -1247,7 +1246,7 @@ local function buildFullScreen() createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") - local bandValues = { "Off", "A", "B", "E", "F", "R", "L" } + local bandValues = { "--", "A", "B", "E", "F", "R", "L" } for i = 1, 6 do local idx = i local ctrl = createRow(fields, table.concat({ "Preset ", idx })) From 749492ec75541c97a123c30d8a4dedd880d7859c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:55:17 +0300 Subject: [PATCH 132/218] stop an already-high Send VTx trigger firing when it is assigned processPushSource seeded Presets.pushLastVal to -1 as a "no sample yet" sentinel, but the edge test was pushLastVal > 0, which turns "unknown" into "was low". A trigger source already high on the first sample therefore read as a low-to-high edge and pushed to the VTX with nobody having touched it. At widget create that was harmless - pushToVtx() bails as not ready - but the editor resets the sentinel the moment a source is picked, and the protocol is ready there. Assigning a two-position switch that happens to be on, or assigning one while holding a momentary button, sent an unrequested Send VTx immediately. Track the level as pushLastHigh, nil until sampled, and have the first sample adopt it without firing. The -1 was also serving as the getValue()-returned-nil fallback, which is a different condition; that now returns without sampling rather than being recorded as low. Verified with a trigger source that reads high from the first sample: eleven spurious pushes before, none after. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index a4c81aa..f614f4a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -471,8 +471,9 @@ Presets = { stableTime = 0, DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) - -- Push source edge detection state - pushLastVal = -1, + -- Push source edge detection state. nil until the first sample has been taken. + ---@type boolean? + pushLastHigh = nil, } -- ============================================================================ @@ -680,12 +681,18 @@ function Presets.processPushSource() local val = getValue(Presets.pushSource) if val == nil then - val = -1 + return end + local high = val > 0 + local wasHigh = Presets.pushLastHigh + Presets.pushLastHigh = high - local wasHigh = Presets.pushLastVal > 0 - Presets.pushLastVal = val + if wasHigh == nil then + -- First sample after create or reassignment: adopt the level without firing. A + -- source that is already high was not just moved there by the user. + return + end -- Edge detection: trigger only on rising edge (low -> high) if high and not wasHigh then @@ -1234,7 +1241,7 @@ local function buildFullScreen() end, function(v) Presets.pushSource = v or 0 - Presets.pushLastVal = -1 + Presets.pushLastHigh = nil Presets.save() end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, From 93b8ab50d995ba20db0d872f7d958a21589250e8 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:55:25 +0300 Subject: [PATCH 133/218] say what Auto Push to VTX does Send VTx Trigger carries a hint line explaining itself. Auto Push to VTX sits directly above it, decides whether that trigger does anything at all, and said nothing. createRow already renders a hint as a SMLSIZE sub-label, so createToggleRow only needed to forward one. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index f614f4a..86555ec 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1047,8 +1047,8 @@ local function createNumberRow(container, label, min, max, getFn, setFn, editedF }) end -local function createToggleRow(container, label, getFn, setFn, visibleFn) - local ctrl = createRow(container, label, nil, visibleFn) +local function createToggleRow(container, label, getFn, setFn, visibleFn, hint) + local ctrl = createRow(container, label, hint, visibleFn) ctrl:toggle({ get = getFn, set = setFn, @@ -1231,7 +1231,7 @@ local function buildFullScreen() end, function(v) Presets.autoPushVtx = (v == 1) Presets.save() - end) + end, nil, "Send to the VTX as soon as the 6POS position changes. When off, use the trigger below.") createSourceRow( fields, From 60f1d3fec2c965b22cb09304b0e42e5c1999302a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 09:55:33 +0300 Subject: [PATCH 134/218] stop tracking the VTX Admin presets file presets.txt is runtime state the widget writes, and the tracked copy held one developer's settings - 6POS enabled, auto push on, and raw source indices that mean different inputs on different radios. edgetx.yml already excludes it from both pkg install and dev sync so it never shipped, but it sat in the tree as if it were a default. The .gitignore entry meant to cover it still named color/WIDGETS, the path from before the unified-LCD migration, so it had stopped matching anything. Point it at src/WIDGETS and untrack the file; Presets.load() falls back to disabled with no source and R1-R6 when it is absent. --- .gitignore | 2 +- src/WIDGETS/ELRSVTXAdmin/presets.txt | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) delete mode 100644 src/WIDGETS/ELRSVTXAdmin/presets.txt diff --git a/.gitignore b/.gitignore index addd2f5..26e3198 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ *.luac -color/WIDGETS/ELRSVTXAdmin/presets.txt +src/WIDGETS/ELRSVTXAdmin/presets.txt bin/ .claude/plans/ TODO.md diff --git a/src/WIDGETS/ELRSVTXAdmin/presets.txt b/src/WIDGETS/ELRSVTXAdmin/presets.txt deleted file mode 100644 index 54f72a9..0000000 --- a/src/WIDGETS/ELRSVTXAdmin/presets.txt +++ /dev/null @@ -1,10 +0,0 @@ -enabled=1 -source=92 -autoPushVtx=1 -pushSource=94 -p1=4,6 -p2=5,2 -p3=5,3 -p4=5,4 -p5=5,5 -p6=5,6 From e50a87c275c00d26d5258065b10d255d9bf1c802 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 11:46:14 +0300 Subject: [PATCH 135/218] Share one telemetry snapshot per tick in the ELRS Telemetry widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readLink() was called from inside every LVGL text callback — 18 sites across 7 files — so a full-screen redraw made 54 getValue() calls and allocated a table per label. Replace it with Telemetry.link, refilled once per tick by Telemetry.update() from wgt.background(). EdgeTX runs refresh() immediately before evaluating every text/color callback, so labels still read values sampled in their own frame. Derived state moves into the same update: - rangePct computed once, so the range colour no longer lags a frame behind its text (color callbacks run before text callbacks) - checkCellCount runs once per tick and regains the original comments; cellCntCnt initialises to nil so the (or 0) coalesce is live - resetConnection() clears cell count, smoothing and diversity on the telemetry falling edge, as the original did on RX disconnect — without it a 6S->4S swap kept the stale S count Add a 1/2 tier so the battery row stops being clipped: the ladder went third (3 rows) straight to full (5 rows), so zones between them lost the last row. buildHalf draws the four data rows without the title, gated by a halfH breakpoint sized from the firmware font tables. portrait needs no rung — its thirdH already clears five rows. Also dedupe the battery label across the five screen files, drop topbar's private getRssi copy, and order topbar's hasTelemetry check before modelMismatch to match statusText(). Rename the simulator mock's RX to Bob 2400RX. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 2 +- src/WIDGETS/ELRSTelemetry/loadable.lua | 174 +++++++++++++------- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 99 +++++------ src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 88 +++++----- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 99 +++++------ src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 99 +++++------ src/WIDGETS/ELRSTelemetry/ui/small.lua | 96 +++++------ src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 25 +-- 8 files changed, 369 insertions(+), 313 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index e219b2d..c3f9e80 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -609,7 +609,7 @@ local txDevice = { local rxDevice = { id = CRSF.ADDRESS_RX, - name = "ELRS 2400RX", + name = "Bob 2400RX", serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, swVer = 0x00030500, -- 3.5.0 diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index eb012b8..9ec457f 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -21,18 +21,29 @@ Telemetry = { -- Smoothed range percentage smoothRng = nil, + -- Range percentage, recomputed once per tick by Telemetry.update() + rangePct = 0, + -- Cell count detection state cellCnt = nil, - cellCntCnt = 0, + ---@type number|nil + cellCntCnt = nil, cellLastV = nil, -- Diversity detection isDiversity = false, + -- Connection state, used to detect the falling edge on disconnect + wasConnected = false, + -- Cached GPS position (persists across disconnects) ---@type {lat: number, lon: number}|nil gps = nil, + -- Shared link snapshot, refilled once per tick by Telemetry.update(). + -- The table identity never changes, so consumers may cache a reference. + link = {}, + -- Power level mapping table POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 }, } @@ -49,11 +60,14 @@ end --- Cell count detection heuristic (same logic as original). function Telemetry.checkCellCount(v) + -- once the cellCnt is the same X times in a row, stop updating if (Telemetry.cellCntCnt or 0) > 5 then return end + -- try to lock on to the cell count, so as the voltage sags we don't change S local cellCnt = math.floor(v / 4.35) + 1 + -- Prevent lock on when no voltage is present if (v / cellCnt) < 3.0 then return end @@ -62,6 +76,7 @@ function Telemetry.checkCellCount(v) Telemetry.cellCnt = cellCnt Telemetry.cellCntCnt = 0 else + -- The value has to change to count as an update if Telemetry.cellLastV == v then return end @@ -70,18 +85,6 @@ function Telemetry.checkCellCount(v) end end ---- Read all link telemetry values into a table. -function Telemetry.readLink() - return { - tpwr = crsf.getSensorValue("TPWR"), - rfmd = crsf.getSensorValue("RFMD"), - rssi1 = crsf.getSensorValue("1RSS"), - rssi2 = crsf.getSensorValue("2RSS"), - rqly = crsf.getSensorValue("RQly"), - ant = crsf.getSensorValue("ANT"), - } -end - --- Check if a CRSF/ELRS module is available. function Telemetry.hasModule() return crsf.hasCrsfModule() @@ -149,7 +152,7 @@ function Telemetry.updateDiversity(ant) end end ---- Pick the active antenna's RSSI value from a readLink() result. +--- Pick the active antenna's RSSI value from a link snapshot. function Telemetry.getRssi(tlm) if not tlm then return nil @@ -157,6 +160,50 @@ function Telemetry.getRssi(tlm) return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 end +--- Clear per-connection state. +--- The original discarded its whole ctx table on RX disconnect, so cell count and +--- range smoothing re-detect on the next battery instead of latching forever. +--- gps is deliberately kept — the port caches last-known position across dropouts. +function Telemetry.resetConnection() + Telemetry.smoothRng = nil + Telemetry.rangePct = 0 + Telemetry.cellCnt = nil + Telemetry.cellCntCnt = nil + Telemetry.cellLastV = nil + Telemetry.isDiversity = false +end + +--- Refill the shared link snapshot and recompute all derived state. +--- Called once per tick from wgt.background(). EdgeTX runs wgt.refresh() immediately +--- before it evaluates every LVGL text/color callback, so labels always read values +--- sampled in their own frame and every field sees the same snapshot. +function Telemetry.update() + local link = Telemetry.link + link.tpwr = crsf.getSensorValue("TPWR") + link.rfmd = crsf.getSensorValue("RFMD") + link.rssi1 = crsf.getSensorValue("1RSS") + link.rssi2 = crsf.getSensorValue("2RSS") + link.rqly = crsf.getSensorValue("RQly") + link.ant = crsf.getSensorValue("ANT") + link.vbat = crsf.getSensorValue("RxBt") + + local connected = crsf.hasTelemetry + if Telemetry.wasConnected and not connected then + Telemetry.resetConnection() + end + Telemetry.wasConnected = connected + if not connected then + return + end + + Telemetry.updateDiversity(link.ant) + Telemetry.updateGps() + Telemetry.rangePct = Telemetry.getRangePct(link) + if link.vbat then + Telemetry.checkCellCount(link.vbat) + end +end + --- Map range percentage to a warning color. function Telemetry.rangeColor(pct) if pct > 90 then @@ -173,10 +220,8 @@ function Telemetry.signalText() if not crsf.hasTelemetry then return "" end - local tlm = Telemetry.readLink() - local pct = Telemetry.getRangePct(tlm) - local parts = { table.concat({ "Range ", tostring(pct), "%" }) } - local rssi = Telemetry.getRssi(tlm) + local parts = { table.concat({ "Range ", tostring(Telemetry.rangePct), "%" }) } + local rssi = Telemetry.getRssi(Telemetry.link) if rssi then parts[#parts + 1] = table.concat({ tostring(rssi), "dBm" }) end @@ -185,15 +230,40 @@ end --- RF mode + TX power text (e.g. "250Hz 50mW"). function Telemetry.rfDetailText() - local tlm = Telemetry.readLink() - local mode = Telemetry.getRfModeStr(tlm.rfmd) - local parts = { mode } + local tlm = Telemetry.link + local parts = { Telemetry.getRfModeStr(tlm.rfmd) } if crsf.hasTelemetry and tlm.tpwr then parts[#parts + 1] = table.concat({ tostring(tlm.tpwr), "mW" }) end return table.concat(parts, " ") end +--- Battery text for minimized layouts (e.g. "Bat 4S 3.80V"). +function Telemetry.batteryText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "" + end + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) +end + +--- Battery text for the full-screen row (e.g. "4S 3.80V (15.20V)"). +function Telemetry.batteryTextVerbose() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + local cells = Telemetry.cellCnt + if cells then + return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) + end + return string.format("%.2fV", vbat) +end + -- ============================================================================ -- WidgetLayout: minimized zone container builders -- ============================================================================ @@ -408,38 +478,36 @@ local function buildFullScreen() createSectionHeader(fields, "Link Status") createDisplayRow(fields, "RF Mode", function() - local tlm = Telemetry.readLink() - return Telemetry.getRfModeStr(tlm.rfmd) + return Telemetry.getRfModeStr(Telemetry.link.rfmd) end) createDisplayRow(fields, "Link Quality", function() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - return table.concat({ tostring(tlm.rqly or 0), "%" }) + return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) end) createDisplayRow(fields, "RSSI 1", function() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - if tlm.rssi1 == nil then + local rssi1 = Telemetry.link.rssi1 + if rssi1 == nil then return "--" end - return table.concat({ tostring(tlm.rssi1), " dBm" }) + return table.concat({ tostring(rssi1), " dBm" }) end) createDisplayRow(fields, "RSSI 2", function() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - if tlm.rssi2 == nil then + local rssi2 = Telemetry.link.rssi2 + if rssi2 == nil then return "--" end - return table.concat({ tostring(tlm.rssi2), " dBm" }) + return table.concat({ tostring(rssi2), " dBm" }) end, function() if not Telemetry.isDiversity then return COLOR_THEME_DISABLED @@ -451,20 +519,17 @@ local function buildFullScreen() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() if not Telemetry.isDiversity then return "N/A" end - return (tlm.ant == 1) and "2" or "1" + return (Telemetry.link.ant == 1) and "2" or "1" end) createDisplayRow(fields, "Range", function() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - local pct = Telemetry.getRangePct(tlm) - return table.concat({ tostring(pct), "%" }) + return table.concat({ tostring(Telemetry.rangePct), "%" }) end) -- Power section @@ -474,39 +539,28 @@ local function buildFullScreen() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - if tlm.tpwr == nil then + local tpwr = Telemetry.link.tpwr + if tpwr == nil then return "--" end - return table.concat({ tostring(tlm.tpwr), " mW" }) + return table.concat({ tostring(tpwr), " mW" }) end) createDisplayRow(fields, "Power Index", function() if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - if tlm.tpwr == nil then + local tpwr = Telemetry.link.tpwr + if tpwr == nil then return "--" end - return tostring(Telemetry.pwrToIdx(tlm.tpwr)) + return tostring(Telemetry.pwrToIdx(tpwr)) end) -- Flight Controller section createSectionHeader(fields, "Flight Controller") - createDisplayRow(fields, "Battery", function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "--" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) - end - return string.format("%.2fV", vbat) - end) + createDisplayRow(fields, "Battery", Telemetry.batteryTextVerbose) createDisplayRow(fields, "Current", function() local curr = crsf.getSensorValue("Curr") @@ -579,17 +633,11 @@ function wgt.background() crsf:poll() crsf:requestDeviceInfo() crsf:requestElrsStatus() - Telemetry.updateGps() + Telemetry.update() end function wgt.refresh(_event, _touchState) wgt.background() - - -- Update diversity detection each tick - if crsf.hasTelemetry then - local tlm = Telemetry.readLink() - Telemetry.updateDiversity(tlm.ant) - end end function wgt.update(newOptions) @@ -601,6 +649,10 @@ function wgt.update(newOptions) end end +-- Populate the snapshot before the first paint: update() runs callRefs without a +-- preceding refresh(), so label callbacks can fire before the first background tick. +Telemetry.update() + -- Initial build WidgetUI.build(wgt.zone, wgt.options) diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index b1811c9..8f46ac9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -17,6 +17,7 @@ WidgetUI.breakpoints = { sixthH = 74, quarterH = 104, thirdH = 146, + halfH = 154, } WidgetUI.fonts = { @@ -41,7 +42,7 @@ local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end - return Telemetry.rangeColor(Telemetry.smoothRng or 0) + return Telemetry.rangeColor(Telemetry.rangePct) end local function heroTextLq() @@ -49,8 +50,7 @@ local function heroTextLq() if status then return status end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end -- ============================================================================ @@ -208,6 +208,52 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end +--- Data rows shared by the 1/2 and 1/1 tiers: +--- LQ, Range/RSSI, RF mode/power, battery. +local function appendDataRows(rows) + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return 0 + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.batteryText, + } +end + +--- 1/2: the four data rows without the title. +--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +function WidgetUI.buildHalf(w, h, opa) + local rows = {} + appendDataRows(rows) + WidgetLayout.column(w, h, opa, rows) +end + --- 1/1: full telemetry display with title and large fonts. function WidgetUI.buildFull(w, h, opa) local rows = { @@ -218,51 +264,8 @@ function WidgetUI.buildFull(w, h, opa) color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, - { - type = lvgl.LABEL, - align = LEFT, - font = function() - if Telemetry.statusText() then - return 0 - end - return MIDSIZE - end, - color = heroColorMismatch, - text = heroTextLq, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) - end, - }, } + appendDataRows(rows) WidgetLayout.column(w, h, opa, rows) end @@ -280,6 +283,8 @@ function WidgetUI.build(wgtZone, opts) WidgetUI.buildQuarter(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) else WidgetUI.buildFull(w, h, opa) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 9b57dc2..20d9fd9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -41,7 +41,7 @@ local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end - return Telemetry.rangeColor(Telemetry.smoothRng or 0) + return Telemetry.rangeColor(Telemetry.rangePct) end local function heroTextLq() @@ -49,8 +49,7 @@ local function heroTextLq() if status then return status end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end -- ============================================================================ @@ -178,6 +177,44 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end +--- Data rows shared by the 1/2 and 1/1 tiers: +--- LQ, Range/RSSI, RF mode/power, battery. +local function appendDataRows(rows) + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.batteryText, + } +end + --- 1/1: full telemetry display with title. function WidgetUI.buildFull(w, h, opa) local rows = { @@ -188,51 +225,8 @@ function WidgetUI.buildFull(w, h, opa) color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, - { - type = lvgl.LABEL, - align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, - color = heroColorMismatch, - text = heroTextLq, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) - end, - }, } + appendDataRows(rows) WidgetLayout.column(w, h, opa, rows) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 24dfb31..82b59c5 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -17,6 +17,7 @@ WidgetUI.breakpoints = { sixthH = 37, quarterH = 52, thirdH = 73, + halfH = 108, } WidgetUI.fonts = { @@ -41,7 +42,7 @@ local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end - return Telemetry.rangeColor(Telemetry.smoothRng or 0) + return Telemetry.rangeColor(Telemetry.rangePct) end local function heroTextLq() @@ -49,8 +50,7 @@ local function heroTextLq() if status then return status end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end -- ============================================================================ @@ -195,6 +195,52 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end +--- Data rows shared by the 1/2 and 1/1 tiers: +--- LQ, Range/RSSI, RF mode/power, battery. +local function appendDataRows(rows) + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.batteryText, + } +end + +--- 1/2: the four data rows without the title. +--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +function WidgetUI.buildHalf(w, h, opa) + local rows = {} + appendDataRows(rows) + WidgetLayout.column(w, h, opa, rows) +end + --- 1/1: full telemetry display with title. function WidgetUI.buildFull(w, h, opa) local rows = { @@ -205,51 +251,8 @@ function WidgetUI.buildFull(w, h, opa) color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, - { - type = lvgl.LABEL, - align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, - color = heroColorMismatch, - text = heroTextLq, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) - end, - }, } + appendDataRows(rows) WidgetLayout.column(w, h, opa, rows) end @@ -267,6 +270,8 @@ function WidgetUI.build(wgtZone, opts) WidgetUI.buildQuarter(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) else WidgetUI.buildFull(w, h, opa) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 29feae0..94ab39b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -18,6 +18,7 @@ WidgetUI.breakpoints = { sixthH = 44, quarterH = 62, thirdH = 86, + halfH = 108, } WidgetUI.fonts = { @@ -42,7 +43,7 @@ local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end - return Telemetry.rangeColor(Telemetry.smoothRng or 0) + return Telemetry.rangeColor(Telemetry.rangePct) end local function heroTextLq() @@ -50,8 +51,7 @@ local function heroTextLq() if status then return status end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end -- ============================================================================ @@ -207,6 +207,52 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end +--- Data rows shared by the 1/2 and 1/1 tiers: +--- LQ, Range/RSSI, RF mode/power, battery. +local function appendDataRows(rows) + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.batteryText, + } +end + +--- 1/2: the four data rows without the title. +--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +function WidgetUI.buildHalf(w, h, opa) + local rows = {} + appendDataRows(rows) + WidgetLayout.column(w, h, opa, rows) +end + --- 1/1: full telemetry display with title. function WidgetUI.buildFull(w, h, opa) local rows = { @@ -217,51 +263,8 @@ function WidgetUI.buildFull(w, h, opa) color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, - { - type = lvgl.LABEL, - align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, - color = heroColorMismatch, - text = heroTextLq, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) - end, - }, } + appendDataRows(rows) WidgetLayout.column(w, h, opa, rows) end @@ -279,6 +282,8 @@ function WidgetUI.build(wgtZone, opts) WidgetUI.buildQuarter(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) else WidgetUI.buildFull(w, h, opa) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 425b325..4921b40 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -18,6 +18,7 @@ WidgetUI.breakpoints = { sixthH = 30, quarterH = 42, thirdH = 58, + halfH = 82, } WidgetUI.fonts = { @@ -42,7 +43,7 @@ local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 end - return Telemetry.rangeColor(Telemetry.smoothRng or 0) + return Telemetry.rangeColor(Telemetry.rangePct) end local function heroTextLq() @@ -50,8 +51,7 @@ local function heroTextLq() if status then return status end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end -- ============================================================================ @@ -109,8 +109,7 @@ function WidgetUI.buildSixth(w, h, opa) font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = function() - local tlm = Telemetry.readLink() - return Telemetry.getRfModeStr(tlm.rfmd) + return Telemetry.getRfModeStr(Telemetry.link.rfmd) end, }, }, @@ -197,6 +196,49 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end +--- Data rows shared by the 1/2 and 1/1 tiers: +--- LQ, Range/RSSI, RF mode/power, battery. +local function appendDataRows(rows) + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + return BOLD + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.batteryText, + } +end + +--- 1/2: the four data rows without the title. +--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +function WidgetUI.buildHalf(w, h, opa) + local rows = {} + appendDataRows(rows) + WidgetLayout.column(w, h, opa, rows) +end + --- 1/1: full telemetry display with title. function WidgetUI.buildFull(w, h, opa) local rows = { @@ -207,48 +249,8 @@ function WidgetUI.buildFull(w, h, opa) color = COLOR_THEME_SECONDARY1, text = "ExpressLRS", }, - { - type = lvgl.LABEL, - align = LEFT, - font = function() - return BOLD - end, - color = heroColorMismatch, - text = heroTextLq, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = function() - local vbat = crsf.getSensorValue("RxBt") - if vbat == nil or vbat <= 0 then - return "" - end - Telemetry.checkCellCount(vbat) - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) - end, - }, } + appendDataRows(rows) WidgetLayout.column(w, h, opa, rows) end @@ -266,6 +268,8 @@ function WidgetUI.build(wgtZone, opts) WidgetUI.buildQuarter(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) else WidgetUI.buildFull(w, h, opa) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 06c9ebc..9030128 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -9,13 +9,6 @@ local Telemetry = ctx.Telemetry local TopBarUI = {} -local function getRssi(tlm) - if not tlm then - return nil - end - return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 -end - --- Top bar: two lines stacked, no background. function TopBarUI.build(w, h) lvgl.build({ @@ -40,14 +33,13 @@ function TopBarUI.build(w, h) return COLOR_THEME_PRIMARY2 end, text = function() - if crsf.modelMismatch then - return "Model" - end if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + if crsf.modelMismatch then + return "Model" + end + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end, }, { @@ -61,14 +53,13 @@ function TopBarUI.build(w, h) return COLOR_THEME_PRIMARY2 end, text = function() - if crsf.modelMismatch then - return "Mismatch" - end if not crsf.hasTelemetry then return "--" end - local tlm = Telemetry.readLink() - local rssi = getRssi(tlm) + if crsf.modelMismatch then + return "Mismatch" + end + local rssi = Telemetry.getRssi(Telemetry.link) if rssi == nil then return "" end From 4dc45f228c8dea3066a6785587fa0fefa6d7db9a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 12:12:32 +0300 Subject: [PATCH 136/218] Gate model mismatch on link state, dedupe heroColor and rfModeText modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means anything while connected. The full-screen banner, topbar's two colour callbacks and heroColorMismatch() in all five screen files read it ungated, so a stale flag could paint a label RED while its own text read "--". Added Telemetry.isMismatch() and used it there; the remaining reads already sat behind a not-hasTelemetry early return. heroColorMismatch() was duplicated verbatim across the five screen files and is now Telemetry.heroColor(); topbar keeps a local mismatchColor() for its PRIMARY2 base. small.lua's inline RF-mode reader, rfDetailText() and the full-screen RF Mode row now share Telemetry.rfModeText(). Not reproducible in the simulator: the mock always sets and clears both flags together. --- src/WIDGETS/ELRSTelemetry/loadable.lua | 36 ++++++++++++++++------- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 15 +++------- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 15 +++------- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 15 +++------- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 15 +++------- src/WIDGETS/ELRSTelemetry/ui/small.lua | 19 ++++-------- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 22 +++++++------- 7 files changed, 57 insertions(+), 80 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 9ec457f..3f97908 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -90,6 +90,13 @@ function Telemetry.hasModule() return crsf.hasCrsfModule() end +--- True when a connected link is reporting a model mismatch. +--- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means +--- anything while connected: ungated, a stale flag paints a label RED under text reading "--". +function Telemetry.isMismatch() + return crsf.hasTelemetry and crsf.modelMismatch +end + --- Short status text when not operational or warning active. --- Returns nil when connected with no warnings. --- Used by both full-screen and minimized UIs. @@ -204,6 +211,14 @@ function Telemetry.update() end end +--- Hero label colour: red only while a connected link reports a model mismatch. +function Telemetry.heroColor() + if Telemetry.isMismatch() then + return RED + end + return COLOR_THEME_PRIMARY1 +end + --- Map range percentage to a warning color. function Telemetry.rangeColor(pct) if pct > 90 then @@ -228,12 +243,17 @@ function Telemetry.signalText() return table.concat(parts, " ") end +--- RF mode text (e.g. "250Hz"). Narrow zones use this without the power suffix. +function Telemetry.rfModeText() + return Telemetry.getRfModeStr(Telemetry.link.rfmd) +end + --- RF mode + TX power text (e.g. "250Hz 50mW"). function Telemetry.rfDetailText() - local tlm = Telemetry.link - local parts = { Telemetry.getRfModeStr(tlm.rfmd) } - if crsf.hasTelemetry and tlm.tpwr then - parts[#parts + 1] = table.concat({ tostring(tlm.tpwr), "mW" }) + local parts = { Telemetry.rfModeText() } + local tpwr = Telemetry.link.tpwr + if crsf.hasTelemetry and tpwr then + parts[#parts + 1] = table.concat({ tostring(tpwr), "mW" }) end return table.concat(parts, " ") end @@ -468,18 +488,14 @@ local function buildFullScreen() font = BOLD, color = RED, text = "Model Mismatch — RC commands not sent", - visible = function() - return crsf.modelMismatch - end, + visible = Telemetry.isMismatch, }, }) -- Link Status section createSectionHeader(fields, "Link Status") - createDisplayRow(fields, "RF Mode", function() - return Telemetry.getRfModeStr(Telemetry.link.rfmd) - end) + createDisplayRow(fields, "RF Mode", Telemetry.rfModeText) createDisplayRow(fields, "Link Quality", function() if not crsf.hasTelemetry then diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 8f46ac9..8b44fa1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -31,13 +31,6 @@ WidgetUI.fonts = { -- Minimized display helpers -- ============================================================================ -local function heroColorMismatch() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY1 -end - local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 @@ -78,7 +71,7 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, }, @@ -133,7 +126,7 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, { @@ -187,7 +180,7 @@ function WidgetUI.buildThird(w, h, opa) end return MIDSIZE end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { @@ -220,7 +213,7 @@ local function appendDataRows(rows) end return MIDSIZE end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 20d9fd9..4f68726 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -30,13 +30,6 @@ WidgetUI.fonts = { -- Minimized display helpers -- ============================================================================ -local function heroColorMismatch() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY1 -end - local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 @@ -77,7 +70,7 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, }, @@ -118,7 +111,7 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, { @@ -156,7 +149,7 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { @@ -189,7 +182,7 @@ local function appendDataRows(rows) end return MIDSIZE end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 82b59c5..05961fb 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -31,13 +31,6 @@ WidgetUI.fonts = { -- Minimized display helpers -- ============================================================================ -local function heroColorMismatch() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY1 -end - local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 @@ -78,7 +71,7 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, }, @@ -133,7 +126,7 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, { @@ -174,7 +167,7 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { @@ -207,7 +200,7 @@ local function appendDataRows(rows) end return MIDSIZE end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 94ab39b..0f9018a 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -32,13 +32,6 @@ WidgetUI.fonts = { -- Minimized display helpers -- ============================================================================ -local function heroColorMismatch() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY1 -end - local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 @@ -79,7 +72,7 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, }, @@ -133,7 +126,7 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, { @@ -186,7 +179,7 @@ function WidgetUI.buildThird(w, h, opa) end return BOLD end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { @@ -219,7 +212,7 @@ local function appendDataRows(rows) end return MIDSIZE end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 4921b40..cb95857 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -32,13 +32,6 @@ WidgetUI.fonts = { -- Minimized display helpers -- ============================================================================ -local function heroColorMismatch() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY1 -end - local function detailColor() if not crsf.hasTelemetry then return COLOR_THEME_SECONDARY1 @@ -79,7 +72,7 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, }, @@ -108,9 +101,7 @@ function WidgetUI.buildSixth(w, h, opa) y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = function() - return Telemetry.getRfModeStr(Telemetry.link.rfmd) - end, + text = Telemetry.rfModeText, }, }, }, @@ -135,7 +126,7 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = BOLD, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, }, { @@ -175,7 +166,7 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { @@ -205,7 +196,7 @@ local function appendDataRows(rows) font = function() return BOLD end, - color = heroColorMismatch, + color = Telemetry.heroColor, text = heroTextLq, } rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 9030128..80ffd5a 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -9,6 +9,14 @@ local Telemetry = ctx.Telemetry local TopBarUI = {} +--- Top bar sits on the dark header, so it needs PRIMARY2 rather than Telemetry.heroColor's PRIMARY1. +local function mismatchColor() + if Telemetry.isMismatch() then + return RED + end + return COLOR_THEME_PRIMARY2 +end + --- Top bar: two lines stacked, no background. function TopBarUI.build(w, h) lvgl.build({ @@ -26,12 +34,7 @@ function TopBarUI.build(w, h) type = lvgl.LABEL, align = CENTER, font = SMLSIZE, - color = function() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY2 - end, + color = mismatchColor, text = function() if not crsf.hasTelemetry then return "--" @@ -46,12 +49,7 @@ function TopBarUI.build(w, h) type = lvgl.LABEL, align = CENTER, font = SMLSIZE, - color = function() - if crsf.modelMismatch then - return RED - end - return COLOR_THEME_PRIMARY2 - end, + color = mismatchColor, text = function() if not crsf.hasTelemetry then return "--" From fc3369685645a4125cb562cb626a1b7d982616ba Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 12:31:03 +0300 Subject: [PATCH 137/218] Make the per-screen font table the single source of truth for hero labels WidgetUI.fonts declared hero 20 times across the five screen files and only three were read; every other hero label hardcoded its font inline. Three declarations had drifted from what actually renders: hd.full said DBLSIZE against a MIDSIZE label, sd_tall.third said MIDSIZE against BOLD, and small.full said MIDSIZE against BOLD. Every hero label now reads the table, and the three stale entries were corrected to the value that already rendered, so nothing changes on screen. Tiers whose hero font depends on statusText() declare a second heroStatus entry and go through Telemetry.heroFont(tier), which hands back the plain constant when a tier has no status variant so those labels run no per-frame callback at all. Two degenerate closures went with it: sd_tall's third-tier one returned BOLD from both branches and small's full-tier one ignored its condition entirely. Verified by building all five screens at every tier from both this and the parent revision inside one simulator run and diffing the resolved font of every label: 25 tiers, both the connected and the status branch, zero differences. --- src/WIDGETS/ELRSTelemetry/loadable.lua | 16 ++++++++++++++++ src/WIDGETS/ELRSTelemetry/ui/hd.lua | 22 ++++++---------------- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 13 ++++--------- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 13 ++++--------- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 22 ++++++---------------- src/WIDGETS/ELRSTelemetry/ui/small.lua | 10 ++++------ 6 files changed, 40 insertions(+), 56 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 3f97908..86d19d3 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -219,6 +219,22 @@ function Telemetry.heroColor() return COLOR_THEME_PRIMARY1 end +--- Hero label font for one tier of a screen's WidgetUI.fonts table. +--- Status text ("No CRSF module") is far longer than "LQ 100%", so tiers that would +--- overflow declare a smaller heroStatus and drop to it while a status shows. +--- Tiers without one get the constant back, so no callback runs per frame. +function Telemetry.heroFont(tier) + if not tier.heroStatus then + return tier.hero + end + return function() + if Telemetry.statusText() then + return tier.heroStatus + end + return tier.hero + end +end + --- Map range percentage to a warning color. function Telemetry.rangeColor(pct) if pct > 90 then diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 8b44fa1..9fdc1de 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -23,8 +23,8 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = DBLSIZE, detail = 0 }, + third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, heroStatus = 0, detail = 0 }, } -- ============================================================================ @@ -70,7 +70,7 @@ function WidgetUI.buildSixth(w, h, opa) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - font = BOLD, + font = WidgetUI.fonts.sixth.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -125,7 +125,7 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, w = c1w, align = LEFT, - font = BOLD, + font = WidgetUI.fonts.quarter.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -174,12 +174,7 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, + font = Telemetry.heroFont(WidgetUI.fonts.third), color = Telemetry.heroColor, text = heroTextLq, } @@ -207,12 +202,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return 0 - end - return MIDSIZE - end, + font = Telemetry.heroFont(WidgetUI.fonts.full), color = Telemetry.heroColor, text = heroTextLq, } diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 4f68726..423f7aa 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -23,7 +23,7 @@ WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } -- ============================================================================ @@ -69,7 +69,7 @@ function WidgetUI.buildSixth(w, h, opa) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - font = BOLD, + font = WidgetUI.fonts.sixth.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -110,7 +110,7 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, w = c1w, align = LEFT, - font = BOLD, + font = WidgetUI.fonts.quarter.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -176,12 +176,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, + font = Telemetry.heroFont(WidgetUI.fonts.full), color = Telemetry.heroColor, text = heroTextLq, } diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 05961fb..e2b5512 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -24,7 +24,7 @@ WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } -- ============================================================================ @@ -70,7 +70,7 @@ function WidgetUI.buildSixth(w, h, opa) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - font = BOLD, + font = WidgetUI.fonts.sixth.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -125,7 +125,7 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, w = c1w, align = LEFT, - font = BOLD, + font = WidgetUI.fonts.quarter.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -194,12 +194,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, + font = Telemetry.heroFont(WidgetUI.fonts.full), color = Telemetry.heroColor, text = heroTextLq, } diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 0f9018a..77767a7 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -24,8 +24,8 @@ WidgetUI.breakpoints = { WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, - third = { hero = MIDSIZE, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } -- ============================================================================ @@ -71,7 +71,7 @@ function WidgetUI.buildSixth(w, h, opa) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - font = BOLD, + font = WidgetUI.fonts.sixth.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -125,7 +125,7 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, w = c1w, align = LEFT, - font = BOLD, + font = WidgetUI.fonts.quarter.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -173,12 +173,7 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return BOLD - end, + font = WidgetUI.fonts.third.hero, color = Telemetry.heroColor, text = heroTextLq, } @@ -206,12 +201,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - if Telemetry.statusText() then - return BOLD - end - return MIDSIZE - end, + font = Telemetry.heroFont(WidgetUI.fonts.full), color = Telemetry.heroColor, text = heroTextLq, } diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index cb95857..e9ddca6 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -25,7 +25,7 @@ WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = BOLD, detail = SMLSIZE }, } -- ============================================================================ @@ -71,7 +71,7 @@ function WidgetUI.buildSixth(w, h, opa) { type = lvgl.LABEL, y = lvgl.PAD_SMALL, - font = BOLD, + font = WidgetUI.fonts.sixth.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -125,7 +125,7 @@ function WidgetUI.buildQuarter(w, h, opa) type = lvgl.LABEL, w = c1w, align = LEFT, - font = BOLD, + font = WidgetUI.fonts.quarter.hero, color = Telemetry.heroColor, text = heroTextLq, }, @@ -193,9 +193,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = function() - return BOLD - end, + font = WidgetUI.fonts.full.hero, color = Telemetry.heroColor, text = heroTextLq, } From 8ecf2b3d7099d8272fa910b216f4258ab827541b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 13:19:51 +0300 Subject: [PATCH 138/218] Label the active antenna "Ant 1"/"Ant 2" instead of a bare 1/2 The full-screen telemetry page showed "Active Antenna: 2" while EdgeTX's own telemetry sensor list showed ANT = 1 for the same link, which reads like a bug. The numbering was already right and is unchanged: CRSF defines the field as "enum ant. 1 = 0, ant. 2 = 1" and the sibling 1RSS/2RSS sensors are 1-based, so ANT=0 means antenna 1. EdgeTX simply prints the raw enum (UNIT_RAW, no offset), as does the TX module's own OLED, so two of the three places a pilot can look show 0/1. Prefixing the value with "Ant " stops it being read as that raw number and keeps the row pointing at the RSSI 1 / RSSI 2 rows above it. Not renamed to the RX parameter's "Antenna A/B" wording: that would be a third scheme, and on a dual-radio RX the field reports which radio decoded the packet rather than a physical antenna. The two-branch expression also had a catch-all - a nil ANT while connected rendered "1". It now renders "--", matching RSSI 1, RSSI 2, TX Power and Satellites in the same block. Telemetry.getRssi and getRangePct still fall back to antenna 1's RSSI on a nil ant, so the hero RSSI stays populated while this row admits it doesn't know. No scenario in the CRSF simulator could reach the antenna 1 branch: normal pins ANT to 1, and armed pins it to 0 but then the diversity latch never fires, so it renders N/A. normal now steps ANT through a fixed sequence, ~5 s per antenna; armed keeps its pinned 0 so the N/A state stays reachable somewhere. Verified headless on TX16S (480x272) and Jumper T15 (480x320): Ant 2 and Ant 1 across the toggle, N/A with RSSI 2 greyed under armed, and -- under no_telemetry. --- docs/development.md | 4 ++-- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 22 ++++++++++++++++++++- src/WIDGETS/ELRSTelemetry/loadable.lua | 11 ++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/development.md b/docs/development.md index ca6a9ae..0f2af4b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -61,10 +61,10 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | Scenario | Description | |----------|-------------| -| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | +| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. `ANT` alternates between 1 and 0 every ~5 seconds so both antenna branches render. | | `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | | `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | | `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | -| `armed` | TX + RX connected with "is Armed" warning flag. | +| `armed` | TX + RX connected with "is Armed" warning flag. `ANT` is pinned to 0, which keeps the telemetry widget's non-diversity "N/A" state reachable. | | `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | | `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index c3f9e80..faffbbe 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -1427,6 +1427,21 @@ local sensorJitter = { Alt = 5, } +-- Per-scenario sensors that step through a fixed sequence instead of jittering, +-- so both branches of an enum sensor are reachable within one simulator run. +-- Takes precedence over sensorJitter and over the scenario's base value; one +-- entry is consumed per cache refresh, i.e. one per second. +-- Only normal cycles ANT: armed keeps it pinned to 0 so the widget's +-- non-diversity "N/A" state stays reachable somewhere. +local sensorToggle = { + normal = { + -- Starts on antenna 2 so the widget's diversity latch fires immediately, + -- then falls back so the antenna 1 branch renders too. + ANT = { 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 }, -- ~5 s per antenna + }, +} +local toggleStep = 0 + -- Telemetry values are cached and only refreshed once per second to match -- realistic sensor update rates and avoid excessive CPU in the simulator. local telemetryCache = {} @@ -1458,9 +1473,14 @@ local function updateTelemetryCache() end telemetryCache = {} + toggleStep = toggleStep + 1 + local toggles = sensorToggle[config.scenario] for sensorId, base in pairs(t) do + local seq = toggles and toggles[sensorId] local jit = sensorJitter[sensorId] - if jit then + if seq then + telemetryCache[sensorId] = seq[(toggleStep % #seq) + 1] + elseif jit then local val = base + (math.random() * 2 - 1) * jit if jit == math.floor(jit) then val = math.floor(val + 0.5) diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 86d19d3..3f31bdd 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -554,7 +554,16 @@ local function buildFullScreen() if not Telemetry.isDiversity then return "N/A" end - return (Telemetry.link.ant == 1) and "2" or "1" + -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX + -- module's own screen. The "Ant " prefix keeps this row from reading as that + -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above. + if Telemetry.link.ant == 0 then + return "Ant 1" + end + if Telemetry.link.ant == 1 then + return "Ant 2" + end + return "--" end) createDisplayRow(fields, "Range", function() From 5366f7226cf294f1ef70684b9a6b669246c9097e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 13:30:15 +0300 Subject: [PATCH 139/218] Detect diversity from 2RSS instead of latching on the ANT sensor Telemetry.isDiversity did not mean "diversity RX" - it meant "has reported antenna 2 at least once since the last connect". A dual-radio receiver on a healthy link showed "N/A" for the active antenna and a greyed-out RSSI 2 row until the antenna happened to flip, which on a stable link may be never, and any failsafe cleared the flag again. A receiver only writes uplink_RSSI_2 when it has a second RF path: a dual-radio RX fills it every packet, a switched-antenna RX from its first switch onward. One with a single antenna never touches it, so it arrives as 0 dBm - impossible for a real signal. That is the test ExpressLRS itself uses to decide whether to draw a second RSSI on the TX module's OLED and TFT screens. Telemetry.hasDiversity() reads 2RSS from the shared link snapshot, which Telemetry.update() already samples. The field, the updater, its call site and the resetConnection() clear all go away, so the state is derived rather than latched and survives reconnects. Both consumers - the RSSI 2 greying and the Active Antenna row - now read the one function and cannot disagree. This cannot help a switched-antenna RX before its first switch; that information is not on the wire. ExpressLRS has a trueDiversityAvailable bit in the OTA link stats, but the TX consumes it locally to drop out of Gemini mode and never forwards it to the handset. The simulator had no scenario with a second RF path absent, so the new single_antenna scenario pins 2RSS to 0. Verified headless on Jumper T15: single_antenna gives N/A with RSSI 2 greyed, no_telemetry gives --, normal alternates Ant 1 / Ant 2, and armed - a diversity RX parked on antenna 1 - is the regression case, previously N/A and now Ant 1 with RSSI 2 live. --- docs/development.md | 3 ++- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 30 ++++++++++++++++----- src/WIDGETS/ELRSTelemetry/loadable.lua | 22 +++++++-------- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/development.md b/docs/development.md index 0f2af4b..cc55950 100644 --- a/docs/development.md +++ b/docs/development.md @@ -65,6 +65,7 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | | `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | | `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | -| `armed` | TX + RX connected with "is Armed" warning flag. `ANT` is pinned to 0, which keeps the telemetry widget's non-diversity "N/A" state reachable. | +| `armed` | TX + RX connected with "is Armed" warning flag. `ANT` is pinned to 0. | +| `single_antenna` | TX + RX connected on a receiver with one RF path. `2RSS` is pinned to 0, so the telemetry widget reports no diversity. | | `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | | `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index faffbbe..80f9355 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -1029,7 +1029,7 @@ local function getElrsFlags() return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then return 0x09 -- connected + armed - elseif config.scenario == "normal" or config.scenario == "slow_loading" then + elseif config.scenario == "normal" or config.scenario == "slow_loading" or config.scenario == "single_antenna" then return 0x01 -- connected else return 0x00 -- no telemetry @@ -1363,6 +1363,22 @@ local scenarioTelemetry = { GSpd = 25.3, Alt = 142, }, + -- A receiver with one RF path: it never writes uplink_RSSI_2, so 2RSS arrives + -- as 0 dBm and the widget should report no diversity. + single_antenna = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -84, + ["2RSS"] = 0, + RQly = 97, + ANT = 0, + RxBt = 15.1, + Curr = 11.0, + FM = "ACRO", + Sats = 11, + GSpd = 22.4, + Alt = 120, + }, armed = { TPWR = 250, RFMD = 7, @@ -1430,15 +1446,17 @@ local sensorJitter = { -- Per-scenario sensors that step through a fixed sequence instead of jittering, -- so both branches of an enum sensor are reachable within one simulator run. -- Takes precedence over sensorJitter and over the scenario's base value; one --- entry is consumed per cache refresh, i.e. one per second. --- Only normal cycles ANT: armed keeps it pinned to 0 so the widget's --- non-diversity "N/A" state stays reachable somewhere. +-- entry is consumed per cache refresh, i.e. one per second. A single-entry +-- sequence pins a value that would otherwise be jittered. local sensorToggle = { normal = { - -- Starts on antenna 2 so the widget's diversity latch fires immediately, - -- then falls back so the antenna 1 branch renders too. + -- Alternate antennas so both the "Ant 1" and "Ant 2" branches render. ANT = { 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 }, -- ~5 s per antenna }, + single_antenna = { + -- Must stay exactly 0: that is what marks the second RF path as absent. + ["2RSS"] = { 0 }, + }, } local toggleStep = 0 diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 3f31bdd..57e6d08 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -30,9 +30,6 @@ Telemetry = { cellCntCnt = nil, cellLastV = nil, - -- Diversity detection - isDiversity = false, - -- Connection state, used to detect the falling edge on disconnect wasConnected = false, @@ -152,11 +149,14 @@ function Telemetry.updateGps() end end ---- Update diversity flag from ant value. -function Telemetry.updateDiversity(ant) - if ant and ant ~= 0 then - Telemetry.isDiversity = true - end +--- Whether the RX reports a second antenna. +--- An RX only writes uplink_RSSI_2 when it has two RF paths — a dual-radio RX +--- fills it every packet, a switched-antenna RX once it first selects antenna 2. +--- A single-antenna RX never touches it, so it arrives as 0 dBm, impossible for +--- a real signal. Same test ExpressLRS uses on the TX module's own screens. +function Telemetry.hasDiversity() + local rssi2 = Telemetry.link.rssi2 + return rssi2 ~= nil and rssi2 ~= 0 end --- Pick the active antenna's RSSI value from a link snapshot. @@ -177,7 +177,6 @@ function Telemetry.resetConnection() Telemetry.cellCnt = nil Telemetry.cellCntCnt = nil Telemetry.cellLastV = nil - Telemetry.isDiversity = false end --- Refill the shared link snapshot and recompute all derived state. @@ -203,7 +202,6 @@ function Telemetry.update() return end - Telemetry.updateDiversity(link.ant) Telemetry.updateGps() Telemetry.rangePct = Telemetry.getRangePct(link) if link.vbat then @@ -541,7 +539,7 @@ local function buildFullScreen() end return table.concat({ tostring(rssi2), " dBm" }) end, function() - if not Telemetry.isDiversity then + if not Telemetry.hasDiversity() then return COLOR_THEME_DISABLED end return COLOR_THEME_SECONDARY1 @@ -551,7 +549,7 @@ local function buildFullScreen() if not crsf.hasTelemetry then return "--" end - if not Telemetry.isDiversity then + if not Telemetry.hasDiversity() then return "N/A" end -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX From 2fa4b2377477a3e1d01fd6fd413b08791650be6c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 13:42:32 +0300 Subject: [PATCH 140/218] Correct the radio lists on the sd and sd_tall screen tiers The getScreenId() comments and the ui/sd*.lua file headers had the two 480-wide tiers' radios the wrong way round: sd_tall (480x320) claimed the TX16S family and sd (480x272) claimed the T15 Pro, TX15, ST16 and PL18. It is the other way round. radio/src/targets/horus/hal.h selects 480x320 only under RADIO_T15 and 480x272 for everything else in the family, so a TX16S takes the sd branch. The 480x320 radios live in their own targets - t15pro, tx15, st16 and pl18 - and are the ones that reach sd_tall. Comments only, no behaviour change: getScreenId() already routed on LCD_W/LCD_H and was correct. But the comments are what you read when deciding which radio to launch to exercise a tier, and they sent you to the wrong one. --- src/WIDGETS/ELRSTelemetry/loadable.lua | 4 ++-- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 4 ++-- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 57e6d08..4d3eb21 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -372,9 +372,9 @@ local function getScreenId() elseif w <= 320 then return "small" -- 320x240 elseif h >= 320 then - return "sd_tall" -- 480x320 (TX16S) + return "sd_tall" -- 480x320 (T15, T15 Pro, TX15, ST16, PL18) else - return "sd" -- 480x272 + return "sd" -- 480x272 (TX16S, MAX, Mk II) end end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index e2b5512..bb56a20 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- ELRS Telemetry Widget - UI for 480x272 (SD) -- --- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +-- Standard definition landscape (TX16S, TX16S MAX, TX16S Mark II) -- --------------------------------------------------------------------------- local ctx = ... diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 77767a7..62d4aeb 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- ELRS Telemetry Widget - UI for 480x320 (SD Tall) -- --- TX16S, TX16S MAX, TX16S Mark II -- +-- Jumper T15, T15 Pro, TX15, ST16, PL18 -- --------------------------------------------------------------------------- local ctx = ... diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 86555ec..f036443 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -954,9 +954,9 @@ local function getScreenId() elseif w <= 320 then return "small" -- 320x240 elseif h >= 320 then - return "sd_tall" -- 480x320 (TX16S) + return "sd_tall" -- 480x320 (T15, T15 Pro, TX15, ST16, PL18) else - return "sd" -- 480x272 + return "sd" -- 480x272 (TX16S, MAX, Mk II) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 3162e40..04568d0 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- VTX Administrator Widget - UI for 480x272 (SD) -- --- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +-- Standard definition landscape (TX16S, TX16S MAX, TX16S Mark II) -- --------------------------------------------------------------------------- local ctx = ... diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index f1b1899..7d044c5 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- VTX Administrator Widget - UI for 480x320 (SD Tall) -- --- TX16S, TX16S MAX, TX16S Mark II -- +-- Jumper T15, T15 Pro, TX15, ST16, PL18 -- --------------------------------------------------------------------------- local ctx = ... From e4c2de6e1c62ef1569ff947d1e409e4a25c39fda Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 15:18:10 +0300 Subject: [PATCH 141/218] Fold onElrsStatus's inline string parse into CRSF:fieldGetString crsf.lua carried two null-terminated string parsers 170 lines apart, and they were not equivalent: onElrsStatus guarded its loop on `data[off] and data[off] ~= 0` while the shared CRSF:fieldGetString guarded only on `~= 0`, so a frame with no terminator hit string.char(nil). Swapping the call in as-is would have traded a duplicated loop for a Lua error inside CRSF:poll(). fieldGetString now carries the nil guard, which also fixes onDeviceInfo - its other caller, and the one that could actually reach the fault. onDeviceInfo also committed to the cache before it knew the frame was long enough: a partial parse set info.name and then errored, and since requestDeviceInfo() stops pinging once info.name is set, the retry latched off for good. The version triplet is now checked first. onElrsStatus gains the source-address check onDeviceInfo and the tool's parseElrsInfoMessage already had; ExpressLRS hardcodes CRSF_ADDRESS_CRSF_TRANSMITTER as the 0x2E origin. Verified headless on TX16S by driving poll() with scripted frames: 21 assertions over both handlers. Reverting just the nil guard failed exactly four of them and nothing else. The telemetry widget still renders against the CRSF simulator, so the address check does not reject the live path. --- src/SCRIPTS/ELRS/crsf.lua | 43 +++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 94df53e..4460a8c 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -218,15 +218,20 @@ end -- ============================================================================ --- Parse a null-terminated string from a CRSF data array. --- Modifies data in-place (bytes -> chars) for efficiency. +-- Modifies data in-place (bytes -> chars) for efficiency: poll() hands the +-- same data table to every handler registered for a frame type, so a handler +-- running after this one sees 1-char strings, not bytes, over +-- [off, nextOffset - 2]. -- @param data array of byte values -- @param off 1-based start offset -- @return string, nextOffset function CRSF:fieldGetString(data, off) local startOff = off - while data[off] ~= 0 do - data[off] = string.char(data[off]) + local b = data[off] + while b and b ~= 0 do + data[off] = string.char(b) off = off + 1 + b = data[off] end return table.concat(data, nil, startOff, off - 1), off + 1 end @@ -268,15 +273,19 @@ local function onDeviceInfo(data) end local name, off = CRSF:fieldGetString(data, 3) + -- off is the first byte after the name's null terminator: + -- serNo (4) + hwVer (4) + swVer (4), where swVer's low 3 bytes are maj.min.rev + local vMaj, vMin, vRev = data[off + 9], data[off + 10], data[off + 11] + if not vRev then + return -- frame shorter than the layout; leave the cache so the ping retries + end + local info = CRSF.deviceInfo info.name = name - -- off points past null terminator of name - -- serNo (4 bytes) + hwVer (4 bytes) + swVer (4 bytes) = 12 bytes - -- swVer is at off+8..off+11, but version fields are at specific offsets: - info.vMaj = data[off + 9] - info.vMin = data[off + 10] - info.vRev = data[off + 11] - info.vStr = string.format("%s (%d.%d.%d)", info.name, info.vMaj, info.vMin, info.vRev) + info.vMaj = vMaj + info.vMin = vMin + info.vRev = vRev + info.vStr = string.format("%s (%d.%d.%d)", name, vMaj, vMin, vRev) -- RFMOD / RFRSSI lookup tables (version-dependent) if info.vMaj == 4 then @@ -398,18 +407,16 @@ end -- ELRS_STATUS handler: updates hasTelemetry, modelMismatch, elrsFlagsInfo local function onElrsStatus(data) + if data[2] ~= CRSF.CONST.ADDRESS_TX then + return + end + CRSF.elrsFlags = data[6] or 0 CRSF.hasTelemetry = bit32.btest(CRSF.elrsFlags, 1) CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) - -- Parse null-terminated warning info string starting at data[7] - local parts = {} - local off = 7 - while data[off] and data[off] ~= 0 do - parts[#parts + 1] = string.char(data[off]) - off = off + 1 - end - CRSF.elrsFlagsInfo = table.concat(parts) + -- Null-terminated warning info string starts at data[7] + CRSF.elrsFlagsInfo = CRSF:fieldGetString(data, 7) end -- Register built-in handlers From a2c87349621d2d15a37f7bf8616db3092d2bf39a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 15:46:16 +0300 Subject: [PATCH 142/218] Drop init() and setMock once the ExpressLRS tool has started luaExecStandalone scans the table a TOOLS script returns and then never pops it, so it sits on the standalone Lua stack - a GC root - for the whole tool session (colorlcd/standalone_lua.cpp:89-120). The luaL_unref at line 252 drops the registry reference to init after its single call, but the lingering table still holds it, and init holds setMock as an upvalue. Both stayed resident until the tool was closed. main.lua now returns a named module table so init can clear its own entry, which subsumes the setMock clear: init uniquely captures VERSION, useLvgl and setMock, so releasing it releases those too. B&W and the widget loader both pop the table and are unaffected. crsf.lua clears setMock for consistency, though nothing captures it there - it is chunk-scope, so the closure is already unreachable once the chunk returns CRSF. Verified headless on TX16S: the tool loads with the mock installed, re-enters cleanly with no Lua errors, and both widgets still take live mock telemetry through the shared _crsfSingleton. --- src/SCRIPTS/ELRS/crsf.lua | 2 ++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 13 ++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 4460a8c..3b3f9aa 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -148,6 +148,8 @@ local function setMock() end setMock() +---@diagnostic disable-next-line: cast-local-type +setMock = nil -- ============================================================================ -- Handler registry diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index fc53ae8..4cb38c2 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -120,6 +120,9 @@ end local UI +-- Module table, forward-declared so init() can drop itself once it has run. +local M = {} + local function init() local deps = { App = App, @@ -134,6 +137,11 @@ local function init() end UI.init() setMock() + -- The returned table stays on the standalone Lua stack and pins init(), which holds + -- setMock as an upvalue. Drop both. + ---@diagnostic disable-next-line: cast-local-type + setMock = nil + M.init = nil end -- ============================================================================ @@ -206,4 +214,7 @@ end -- Return -- ============================================================================ -return { init = init, run = run, useLvgl = useLvgl } +M.init = init +M.run = run +M.useLvgl = useLvgl +return M From 3e091201596072f322446c5bab2131c8d42c22e8 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 16:19:28 +0300 Subject: [PATCH 143/218] Restore parseParameterInfoMessage's comments and drop its unused return Four of the five comment blocks from upstream elrs.lua:437-494 were lost in the port, leaving a chunk-reassembly state machine over module-level state with nothing explaining why it does what it does. Restored verbatim at their original positions, and commented the port-only logic upstream never had: the device/field mismatch discard, the length sanity check, the hidden-bit detection that drives fieldHiddenChanged, and the nameStale/reloading guard on the name cache. The fifth block described the return value, which is now gone. Nothing could read it: poll() called the function as a bare statement and ui/lcd.lua never referenced the parser. Redraw is driven from protocol state instead -- #loadQueue in lcd.lua:211 and the isFolderLoaded transition in main.lua:184. --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index feae861..c19f4d9 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -635,20 +635,25 @@ function Protocol.parseDeviceInfoMessage(data) return device, isNew end +-- Handle a parameter settings entry (0x2B) frame. Field data can span several frames, +-- so this reassembles the chunks before decoding one field. function Protocol.parseParameterInfoMessage(data) local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + -- Another device answered, or this is not the field we are waiting for: drop any partial data if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then Protocol.fieldData = nil Protocol.fieldChunk = 0 - return false + return end local field = Protocol.fields[fieldId] local chunksRemain = data[4] + -- If no field or the chunksremain changed when we have data, don't continue if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then - return false + return end local offset + -- If data is chunked, copy it to persistent buffer if chunksRemain > 0 or Protocol.fieldChunk > 0 then Protocol.fieldData = Protocol.fieldData or {} for i = 5, #data do @@ -657,6 +662,7 @@ function Protocol.parseParameterInfoMessage(data) end offset = 1 else + -- All data arrived in one chunk, operate directly on data Protocol.fieldData = data offset = 5 end @@ -664,19 +670,23 @@ function Protocol.parseParameterInfoMessage(data) if chunksRemain > 0 then Protocol.fieldChunk = Protocol.fieldChunk + 1 Protocol.expectChunksRemain = chunksRemain - 1 - return false else + -- Field data stream is now complete, process into a field Protocol.loadQueue[#Protocol.loadQueue] = nil + -- Need at least parent + type + one name byte for the entry to be usable if #Protocol.fieldData > (offset + 2) then field.id = fieldId field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + -- Hidden bit flipped, so the UI's cached list of visible fields has to be rebuilt local wasHidden = field.hidden field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil if field.hidden ~= wasHidden then Protocol.fieldHiddenChanged = true end + -- Passing the old name makes fieldGetStrOrOpts skip the decode and reuse that string, + -- which is only safe while no reload has flagged the name as possibly changed local cachedName = (not field.nameStale and not field.reloading) and field.name or nil field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) field.nameStale = nil @@ -702,8 +712,6 @@ function Protocol.parseParameterInfoMessage(data) Protocol.fieldChunk = 0 Protocol.fieldData = nil - - return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_TX or #Protocol.loadQueue == 0 end end From da0bd278b4ea30a686b19fd75114cd0a20c4c534 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 16:19:37 +0300 Subject: [PATCH 144/218] Load the ELRS shim in CRSFSimulator so the mock works on B&W radios csrfsimulator.lua called table.concat and table.unpack unguarded, and B&W EdgeTX ships without the table library, so the chunk died at load with "attempt to index a nil value (global 'table')" and the ExpressLRS tool could not be exercised on those radios at all. Simulator-only: setMock() is gated on getVersion() ending in "-simu", so real radios never loaded the mock. SCRIPTS/ELRS/shim.lua already carried tableConcat and tableRemove for exactly this and had no consumer until now; the mock's hand-rolled tableRemove goes away in favour of it. The unpack site gets a new charsToString() rather than a tableUnpack polyfill, because a pure-Lua unpack has to recurse once per element and `return t[i], f(...)` is not a tail call, so it cannot be optimised away. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 43 ++++++--------------- src/SCRIPTS/ELRS/shim.lua | 19 ++++++++- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 80f9355..8b1db17 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -9,6 +9,11 @@ -- Returns a table with { pop, push, moduleFound } fields. -- ============================================================================ +-- B&W radios ship without the table library, so every table.* call here goes +-- through the shared ELRS compat layer. Loading this eagerly is safe: the ELRS +-- library is always installed, while this mock is dev-only. +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + -- ============================================================================ -- Configuration: Change scenario here to test different states -- ============================================================================ @@ -121,30 +126,6 @@ local queueHead = 1 local deferredQueue = {} local deferredReady = false --- BW/FreedomTX compatibility: provide a local analogue to table.remove(). --- Supports remove(tbl) and remove(tbl, idx) semantics. -local function tableRemove(tbl, idx) - if table and table.remove then - return table.remove(tbl, idx) - end - - local n = #tbl - local pos = idx - if pos == nil then - pos = n - end - if pos < 1 or pos > n then - return nil - end - - local removed = tbl[pos] - for i = pos, n - 1 do - tbl[i] = tbl[i + 1] - end - tbl[n] = nil - return removed -end - -- Slow loading scenario: time-delayed response queue. -- PARAMETER_READ responses are held here until their delivery time, then -- promoted to the main queue so the Lua script sees realistic latency. @@ -184,7 +165,7 @@ local function queuePop() -- Serve deferred packets only after a nil has been returned (next poll cycle) if deferredReady and #deferredQueue > 0 then - local pkt = tableRemove(deferredQueue, 1) + local pkt = shim.tableRemove(deferredQueue, 1) ---@diagnostic disable-next-line: need-check-nil return pkt.command, pkt.data end @@ -396,10 +377,10 @@ end local ALLAUX_UPDOWN = (function() local opts = {} for i = 1, 10 do - opts[#opts + 1] = table.concat({ "AUX", i, "\192" }) - opts[#opts + 1] = table.concat({ "AUX", i, "\193" }) + opts[#opts + 1] = shim.tableConcat({ "AUX", i, "\192" }) + opts[#opts + 1] = shim.tableConcat({ "AUX", i, "\193" }) end - return table.concat(opts, ";") + return shim.tableConcat(opts, ";") end)() local txDevice = { @@ -512,7 +493,7 @@ local txDevice = { parent = 10, type = CRSF.TEXT_SELECTION, name = "Pitmode", - options = table.concat({ "Off;On;", ALLAUX_UPDOWN }), + options = shim.tableConcat({ "Off;On;", ALLAUX_UPDOWN }), value = 0, units = "", }, @@ -1216,7 +1197,7 @@ local function mockPush(command, data) chars[#chars + 1] = data[i] i = i + 1 end - param.value = (#chars > 0) and string.char(table.unpack(chars)) or "" + param.value = shim.charsToString(chars) -- Bind Phrase: mirror into the Phrase Echo INFO sibling so the UI only -- reflects the change if the STRING write reloads sibling fields. if param.id == 20 then @@ -1313,7 +1294,7 @@ local function mockPop() local i = 1 while i <= #delayedResponseQueue do if now >= delayedResponseQueue[i].deliverAt then - local entry = tableRemove(delayedResponseQueue, i) + local entry = shim.tableRemove(delayedResponseQueue, i) ---@diagnostic disable-next-line: need-check-nil queuePush(entry.command, entry.data) else diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua index 30dc16c..8d854e9 100644 --- a/src/SCRIPTS/ELRS/shim.lua +++ b/src/SCRIPTS/ELRS/shim.lua @@ -2,7 +2,8 @@ -- B&W Compatibility Layer -- -- -- -- Polyfills for standard Lua library functions missing on B&W radios -- --- (table.concat, table.remove) and shared helpers (sensor value cache). -- +-- (table.concat, table.remove) and shared helpers (byte-array decoding, -- +-- sensor value cache). -- -- -- -- Lives in /SCRIPTS/ELRS/ alongside crsf.lua so it is available to -- -- both color widgets and B&W telemetry scripts. -- @@ -61,6 +62,22 @@ else end end +-- ============================================================================ +-- Byte array -> string +-- Stands in for string.char(table.unpack(t)), which needs the table library. +-- Deliberately iterative: a pure-Lua unpack has to recurse once per element +-- (and `return t[i], f(...)` is not a tail call, so it cannot be optimised +-- away), which is not something to hand EdgeTX's Lua stack. +-- ============================================================================ + +function shim.charsToString(t, i, j) + local parts = {} + for k = i or 1, j or #t do + parts[#parts + 1] = string.char(t[k]) + end + return shim.tableConcat(parts) +end + -- ============================================================================ -- Cached sensor value reader -- Wraps getFieldInfo() + getValue() with a string->ID cache so the name From 425ed88766c1c91a30aa9b1f6ada404496413976 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 10 Aug 2026 20:21:19 +0300 Subject: [PATCH 145/218] Share the ELRS CRSF library between the tool and the widgets The tool script carried ~90 lines of verbatim or near-verbatim copies of SCRIPTS/ELRS/crsf.lua and shim.lua: the address/frame-type/field-type constants down to identical comments, pop/push, hasCrsfModule, setMock, and a byte-identical tableConcat polyfill. The copies had already drifted into a bug: crsf.lua declared CMD_CONFIRMED = 3 where the firmware's commandStep_e has lcsAskConfirm = 3, lcsConfirmed = 4 -- latent only because the widgets never send anything past CMD_CLICK. The tool now loads /SCRIPTS/ELRS/crsf.lua and shim.lua and references crsf.CONST directly at every call site; its own shim.lua is deleted and protocol.lua drops its constant table, pop/push and hasCrsfModule. main.lua's setMock goes too -- crsf.lua already self-mocks at load, and the tool talks to CRSF only through the library. The library gains what the tool needs (FIELD_UINT32..INT64, ELRS_SERIAL_ID, ELRS_FLAGS_*) plus the corrected command steps. B&W radios ship without the table library (linit.c gates it on COLORLCD), so crsf.lua's two raw table.* calls now go through the shim -- a prerequisite, since the tool is the first consumer of this code on B&W hardware. Checking the internal pseudo field types against the CRSF spec showed DEVICE = 15 shadowed a real wire type: 0x0F is CRSF_VTX in both the spec and the firmware enum, so a TBS VTX browsed via Other Devices would have rendered its VTX parameters through the device-row handler. The synthetic markers are now 128/129 -- provably unreachable from the wire, whose type byte is masked with 0x7f -- FIELD_VTX = 15 documents the wire truth, and the unreferenced BACK_EXIT (legacy elrs.lua's synthetic EXIT row) is gone. shim.getSensorValue is deleted as a third copy of crsf.getSensorValue with no consumers, and edgetx.yml marks the tool as depending on the ELRS library, matching the widgets. Verified headless on GX12 (B&W) and TX16S: the tool loads and populates the root folder on both, switches to the mock RX through Other Devices, and drives Enable WiFi through ASKCONFIRM -> CONFIRMED -> Executing -- the path the drifted constant would have wedged, since the mock only advances on 4. no_module, slow_loading and model_mismatch scenarios, and both widgets running live alongside the tool, all render correctly. --- README.md | 4 +- docs/development.md | 10 +- edgetx.yml | 2 + src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 2 +- src/SCRIPTS/ELRS/crsf.lua | 33 +++- src/SCRIPTS/ELRS/shim.lua | 22 +-- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 40 +--- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 199 +++++++------------- src/SCRIPTS/TOOLS/ExpressLRS/shim.lua | 27 --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 65 +++---- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 31 +-- src/WIDGETS/ELRSTelemetry/main.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/main.lua | 2 +- 13 files changed, 164 insertions(+), 275 deletions(-) delete mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/shim.lua diff --git a/README.md b/README.md index a80f436..65cda7e 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,12 @@ When done, your SD card should contain: SCRIPTS/ ELRS/ crsf.lua -- shared CRSF protocol library + shim.lua -- BW compatibility shim TOOLS/ ExpressLRS/ main.lua -- entry point protocol.lua -- CRSF protocol handling navigation.lua -- folder navigation - shim.lua -- BW compatibility shim ui/ lvgl.lua -- color LCD UI (LVGL) lcd.lua -- black & white LCD UI @@ -42,7 +42,7 @@ WIDGETS/ ... ``` -The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. +The shared library `SCRIPTS/ELRS/` is required by the configuration tool and both widgets. ### Install with edgetx-cli diff --git a/docs/development.md b/docs/development.md index cc55950..4dfcaa2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,10 +37,16 @@ Run `make help` to list all targets. The Makefile groups them into three categor | `main.lua` | Entry point and run-loop orchestrator | | `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | | `navigation.lua` | Folder and device navigation stack | -| `shim.lua` | Polyfills for BW radios missing standard Lua functions | | `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | | `ui/lcd.lua` | BW LCD interface (text cursor, popups) | +The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too: + +| Module | Purpose | +|--------|---------| +| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry | +| `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | + ## CRSF Simulator The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. @@ -51,7 +57,7 @@ The simulator provides a packet-level mock of `crossfireTelemetryPop` and `cross ### How it works -When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. +When running in the EdgeTX simulator (version string ends with `-simu`), `SCRIPTS/ELRS/crsf.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` at load time and patches its `pop`, `push`, `hasCrsfModule`, and `getSensorValue` functions with the mock implementations. The tool and the widgets both talk to CRSF through that library, so the mock covers all of them. Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to real hardware. diff --git a/edgetx.yml b/edgetx.yml index 41f8f21..0fcd6ff 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -35,6 +35,8 @@ libraries: tools: - name: ExpressLRS path: SCRIPTS/TOOLS/ExpressLRS + depends: + - ELRS widgets: - name: ELRSTelemetry diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 8b1db17..9f93c45 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -41,7 +41,7 @@ local config = { } -- ============================================================================ --- CRSF Protocol Constants (local copies, independent of Protocol.CRSF) +-- CRSF Protocol Constants (local copies, independent of the shared library) -- ============================================================================ local CRSF = { diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 3b3f9aa..b3b811f 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -9,11 +9,13 @@ -- crossfireTelemetryPop() directly; they register callbacks via -- -- registerHandler() and push outgoing frames through CRSF.push(). -- -- -- --- Loaded once via loadScript() from /SCRIPTS/ELRSLib/crsf.lua. -- +-- Loaded once via loadScript() from /SCRIPTS/ELRS/crsf.lua. -- -- Returns a table with protocol constants, handler registry, -- -- pop queue dispatcher, and device info cache. -- --------------------------------------------------------------------------- +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + local CRSF = {} -- ============================================================================ @@ -41,18 +43,35 @@ CRSF.CONST = { FIELD_INT8 = 1, FIELD_UINT16 = 2, FIELD_INT16 = 3, + FIELD_UINT32 = 4, + FIELD_INT32 = 5, + FIELD_UINT64 = 6, + FIELD_INT64 = 7, FIELD_FLOAT = 8, FIELD_TEXT_SELECTION = 9, FIELD_STRING = 10, FIELD_FOLDER = 11, FIELD_INFO = 12, FIELD_COMMAND = 13, + FIELD_VTX = 15, - -- Command states + -- Command steps (commandStep_e in ExpressLRS CRSFParameters.h) CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, - CMD_CONFIRMED = 3, + CMD_CLICK = 1, -- user has clicked the command to execute + CMD_EXECUTING = 2, -- command is executing + CMD_ASKCONFIRM = 3, -- command pending user OK + CMD_CONFIRMED = 4, -- user has confirmed + CMD_CANCEL = 5, -- user has requested cancel + CMD_QUERY = 6, -- UI is requesting status update + + -- ELRS identification (serial number field in DEVICE_INFO) + ELRS_SERIAL_ID = 0x454C5253, + + -- ELRS flags: bits 0-1 are status (connected, status1), + -- bits 2-4 are warnings (model match, armed, warning1), + -- bits 5-7 are critical errors (error connected, error baudrate, critical2) + ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only + ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags -- Folder child list terminator FIELD_LIST_END = 0xFF, @@ -181,7 +200,7 @@ function CRSF:unregisterHandler(frameType, callback) end for i = #handlers, 1, -1 do if handlers[i] == callback then - table.remove(handlers, i) + shim.tableRemove(handlers, i) return end end @@ -235,7 +254,7 @@ function CRSF:fieldGetString(data, off) off = off + 1 b = data[off] end - return table.concat(data, nil, startOff, off - 1), off + 1 + return shim.tableConcat(data, nil, startOff, off - 1), off + 1 end --- Send a DEVICE_PING if device info is not yet available. diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua index 8d854e9..1e69ad0 100644 --- a/src/SCRIPTS/ELRS/shim.lua +++ b/src/SCRIPTS/ELRS/shim.lua @@ -2,8 +2,7 @@ -- B&W Compatibility Layer -- -- -- -- Polyfills for standard Lua library functions missing on B&W radios -- --- (table.concat, table.remove) and shared helpers (byte-array decoding, -- --- sensor value cache). -- +-- (table.concat, table.remove) and shared helpers (byte-array decoding). -- -- -- -- Lives in /SCRIPTS/ELRS/ alongside crsf.lua so it is available to -- -- both color widgets and B&W telemetry scripts. -- @@ -78,23 +77,4 @@ function shim.charsToString(t, i, j) return shim.tableConcat(parts) end --- ============================================================================ --- Cached sensor value reader --- Wraps getFieldInfo() + getValue() with a string->ID cache so the name --- lookup is only done once per sensor. Avoids repeated string searches in --- the source table on every call. --- ============================================================================ - -local vCache = {} - -function shim.getSensorValue(name) - local cid = vCache[name] - if cid == nil then - local info = getFieldInfo(name) - cid = info and info.id or 0 - vCache[name] = cid - end - return cid ~= 0 and getValue(cid) or nil -end - return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 4cb38c2..7de33d8 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -15,8 +15,9 @@ local useLvgl = (lvgl ~= nil) -- Load shared modules -- ============================================================================ -local shim = loadScript("/SCRIPTS/TOOLS/ExpressLRS/shim.lua")() -local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(shim) +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() +local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")() +local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(crsf, shim) local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() -- ============================================================================ @@ -41,7 +42,7 @@ function App.checkCrsfModule() return App.crsfModuleFound end App.crsfModuleChecked = true - App.crsfModuleFound = Protocol.hasCrsfModule() + App.crsfModuleFound = crsf.hasCrsfModule() return App.crsfModuleFound end @@ -81,8 +82,8 @@ end -- Reload at root: switch back to TX device or reload fields + ping. function App.reloadAtRoot() - if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_TX then - local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_TX) + if Protocol.deviceId ~= crsf.CONST.ADDRESS_TX then + local txDevice = Protocol.getDevice(crsf.CONST.ADDRESS_TX) if txDevice then App.loadDevice(txDevice) end @@ -93,27 +94,6 @@ function App.reloadAtRoot() Protocol.pingDevices() end --- ============================================================================ --- Mock data for simulator --- ============================================================================ - -local function setMock() - local _, rv = getVersion() - if string.sub(rv, -5) ~= "-simu" then - return - end - local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") - if mockModule == nil then - return - end - local mock = mockModule() - Protocol.pop = mock.pop - Protocol.push = mock.push - Protocol.hasCrsfModule = function() - return mock.moduleFound - end -end - -- ============================================================================ -- UI loading (deferred to init) -- ============================================================================ @@ -128,6 +108,7 @@ local function init() App = App, Navigation = Navigation, Protocol = Protocol, + crsf = crsf, VERSION = VERSION, } if useLvgl then @@ -136,11 +117,8 @@ local function init() UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua")(deps) end UI.init() - setMock() - -- The returned table stays on the standalone Lua stack and pins init(), which holds - -- setMock as an upvalue. Drop both. - ---@diagnostic disable-next-line: cast-local-type - setMock = nil + -- The returned table stays on the standalone Lua stack and pins init(), + -- which holds VERSION and useLvgl as upvalues. Drop it. M.init = nil end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index c19f4d9..339042a 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -3,73 +3,23 @@ ---- # Shared between BW and LVGL UI implementations # ---- ######################################################################### -local shim = ... +local crsf, shim = ... local Protocol = { - -- EdgeTX module type for CRSF/ELRS - MODULE_TYPE_CROSSFIRE = 5, - - -- CRSF Field Type Constants - CRSF = { - UINT8 = 0, - INT8 = 1, - UINT16 = 2, - INT16 = 3, - UINT32 = 4, - INT32 = 5, - UINT64 = 6, - INT64 = 7, - FLOAT = 8, - TEXT_SELECTION = 9, - STRING = 10, - FOLDER = 11, - INFO = 12, - COMMAND = 13, - -- Internal/extended types (not in official CRSF protocol) - BACK_EXIT = 14, - DEVICE = 15, - DEVICE_FOLDER = 16, - - -- Frame types - FRAMETYPE_DEVICE_PING = 0x28, - FRAMETYPE_DEVICE_INFO = 0x29, - FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, - FRAMETYPE_PARAMETER_READ = 0x2C, - FRAMETYPE_PARAMETER_WRITE = 0x2D, - FRAMETYPE_ELRS_STATUS = 0x2E, - - -- Addresses - ADDRESS_BROADCAST = 0x00, - ADDRESS_HANDSET = 0xEA, -- EdgeTX's official handset address - ADDRESS_RX = 0xEC, - ADDRESS_TX = 0xEE, - ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard CRSF - - -- ELRS identification - ELRS_SERIAL_ID = 0x454C5253, - - -- ELRS flags: bits 0-1 are status (connected, status1), - -- bits 2-4 are warnings (model match, armed, warning1), - -- bits 5-7 are critical errors (error connected, error baudrate, critical2) - ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only - ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags - - -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) - CMD_IDLE = 0, - CMD_CLICK = 1, - CMD_EXECUTING = 2, - CMD_ASKCONFIRM = 3, - CMD_CONFIRMED = 4, - CMD_CANCEL = 5, - CMD_QUERY = 6, - }, + -- Tool-internal pseudo field types for the synthetic device rows in the + -- "Other Devices" list. Values sit above the wire range: the type byte is + -- masked with 0x7f at parse, so a real field type can never exceed 127 -- + -- unlike 15/16, which the previous numbering used and which shadow + -- CRSF_VTX (0x0F) on the wire. + DEVICE = 128, + DEVICE_FOLDER = 129, -- Handlers dispatch table (populated after function definitions) handlers = {}, -- Device identity (used in every CRSF frame) - deviceId = 0xEE, -- ADDRESS_TX (can't self-ref before table is created) - handsetId = 0xEF, -- ADDRESS_HANDSET_ELRS + deviceId = crsf.CONST.ADDRESS_TX, + handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, deviceName = nil, deviceIsELRS_TX = nil, @@ -109,8 +59,8 @@ local Protocol = { -- ============================================================================ function Protocol.reset() - Protocol.deviceId = Protocol.CRSF.ADDRESS_TX - Protocol.handsetId = Protocol.CRSF.ADDRESS_HANDSET_ELRS + Protocol.deviceId = crsf.CONST.ADDRESS_TX + Protocol.handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS Protocol.deviceName = nil Protocol.deviceIsELRS_TX = nil @@ -139,19 +89,12 @@ function Protocol.reset() end -- ============================================================================ --- Telemetry wrappers (replaced by setMock in simulator) +-- Transport: the shared CRSF library (crsf.pop/push are rebound by its mock +-- in the simulator, so always call through the crsf table) -- ============================================================================ -function Protocol.pop() - return crossfireTelemetryPop() -end - -function Protocol.push(command, data) - return crossfireTelemetryPush(command, data) -end - function Protocol.pingDevices() - Protocol.push(Protocol.CRSF.FRAMETYPE_DEVICE_PING, { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_HANDSET }) + crsf.push(crsf.CONST.FRAMETYPE_DEVICE_PING, { crsf.CONST.ADDRESS_BROADCAST, crsf.CONST.ADDRESS_HANDSET }) end -- Check if telemetry is being received from the RX (elrsFlags bit 1) @@ -164,7 +107,7 @@ function Protocol.isModelMismatch() end function Protocol.hasCriticalError() - return Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD + return Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD end -- Response timeout for PARAMETER_READ: @@ -173,17 +116,6 @@ function Protocol.fieldResponseTimeout() return Protocol.deviceIsELRS_TX and 50 or 500 end --- Check if a CRSF-compatible module is available -function Protocol.hasCrsfModule() - for modIdx = 0, 1 do - local mod = model.getModule(modIdx) - if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then - return true - end - end - return false -end - -- Set active device and prepare fields -- Returns true if device changed, false if no change needed function Protocol.setDevice(device) @@ -198,8 +130,8 @@ function Protocol.setDevice(device) Protocol.elrsFlags = 0 Protocol.deviceName = device.name Protocol.fieldsCount = device.fieldCount - Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_TX or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_HANDSET_ELRS or Protocol.CRSF.ADDRESS_HANDSET + Protocol.deviceIsELRS_TX = device.isElrs and device.id == crsf.CONST.ADDRESS_TX or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and crsf.CONST.ADDRESS_HANDSET_ELRS or crsf.CONST.ADDRESS_HANDSET Protocol.allocateFields() Protocol.reloadAllFields() @@ -317,7 +249,7 @@ function Protocol.startBackgroundLoad() Protocol.backgroundLoading = true for i = 1, #Protocol.fields do local field = Protocol.fields[i] - if field.type == Protocol.CRSF.FOLDER and field.children then + if field.type == crsf.CONST.FIELD_FOLDER and field.children then for j = #field.children, 1, -1 do local childId = field.children[j] local child = Protocol.fields[childId] @@ -459,7 +391,7 @@ function Protocol.fieldCommandLoad(field, data, offset) field.timeout = data[offset + 1] local info = Protocol.fieldGetStrOrOpts(data, offset + 2) field.info = (info ~= "") and info or nil - if field.status == Protocol.CRSF.CMD_IDLE then + if field.status == crsf.CONST.CMD_IDLE then -- A command that was actively running just finished (or was cancelled): -- re-read its same-level fields so the current page reflects any values the -- command changed. The guard limits this to the active command -- routine @@ -474,7 +406,7 @@ end function Protocol.fieldFolderLoad(field, data, offset) field.children = {} - while data[offset] and data[offset] ~= 0xFF do + while data[offset] and data[offset] ~= crsf.CONST.FIELD_LIST_END do field.children[#field.children + 1] = data[offset] offset = offset + 1 end @@ -498,7 +430,7 @@ function Protocol.fieldIntSave(field) for i = size - 1, 0, -1 do frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 end - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) end function Protocol.fieldStringSave(field) @@ -515,7 +447,7 @@ function Protocol.fieldStringSave(field) end end frame[#frame + 1] = 0 - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) end -- ============================================================================ @@ -542,7 +474,7 @@ function Protocol.reloadRelatedFields(field) if fieldId ~= field.id and sibling.parent == field.parent - and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) + and (siblingType < crsf.CONST.FIELD_FOLDER or siblingType == crsf.CONST.FIELD_INFO) then sibling.dirty = true sibling.reloading = true @@ -561,14 +493,11 @@ function Protocol.handleCommandSave(field) Protocol.reloadCurField(field) if field.status ~= nil then - if field.status < Protocol.CRSF.CMD_CONFIRMED then - field.status = Protocol.CRSF.CMD_CLICK - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, field.id, field.status } - ) + if field.status < crsf.CONST.CMD_CONFIRMED then + field.status = crsf.CONST.CMD_CLICK + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) Protocol.fieldPopup = field - Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE + Protocol.fieldPopup.lastStatus = crsf.CONST.CMD_IDLE Protocol.fieldTimeout = getTime() + field.timeout end end @@ -576,20 +505,20 @@ end function Protocol.commandConfirm() if Protocol.fieldPopup then - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED } + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED } ) Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED + Protocol.fieldPopup.status = crsf.CONST.CMD_CONFIRMED end end function Protocol.commandCancel() if Protocol.fieldPopup then - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL } ) Protocol.fieldPopup = nil end @@ -600,20 +529,20 @@ end -- ============================================================================ Protocol.handlers = { - [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.UINT32 + 1] = nil, - [Protocol.CRSF.INT32 + 1] = nil, - [Protocol.CRSF.UINT64 + 1] = nil, - [Protocol.CRSF.INT64 + 1] = nil, - [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = Protocol.fieldStringSave }, - [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, - [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, + [crsf.CONST.FIELD_UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_UINT32 + 1] = nil, + [crsf.CONST.FIELD_INT32 + 1] = nil, + [crsf.CONST.FIELD_UINT64 + 1] = nil, + [crsf.CONST.FIELD_INT64 + 1] = nil, + [crsf.CONST.FIELD_FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_STRING + 1] = { load = Protocol.fieldStringLoad, save = Protocol.fieldStringSave }, + [crsf.CONST.FIELD_FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [crsf.CONST.FIELD_INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [crsf.CONST.FIELD_COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, } -- ============================================================================ @@ -631,7 +560,7 @@ function Protocol.parseDeviceInfoMessage(data) end device.name = newName device.fieldCount = data[offset + 12] - device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID + device.isElrs = Protocol.fieldGetValue(data, offset, 4) == crsf.CONST.ELRS_SERIAL_ID return device, isNew end @@ -703,7 +632,7 @@ function Protocol.parseParameterInfoMessage(data) end -- Auto-queue children for root folder (field 0) and during background preloading. - if field.type == Protocol.CRSF.FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then + if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then for i = #field.children, 1, -1 do Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] end @@ -730,7 +659,7 @@ function Protocol.parseElrsInfoMessage(data) end function Protocol.parseElrsV1Message(data) - if (data[1] ~= Protocol.CRSF.ADDRESS_HANDSET) or (data[2] ~= Protocol.CRSF.ADDRESS_TX) then + if (data[1] ~= crsf.CONST.ADDRESS_HANDSET) or (data[2] ~= crsf.CONST.ADDRESS_TX) then return end Protocol.elrsV1Detected = true @@ -746,8 +675,8 @@ function Protocol.poll() local anyNewDevice = false repeat - command, data = Protocol.pop() - if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then + command, data = crsf.pop() + if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then local device, isNew = Protocol.parseDeviceInfoMessage(data) if device.id == Protocol.deviceId then targetDevice = device @@ -755,16 +684,16 @@ function Protocol.poll() if isNew then anyNewDevice = true end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + elseif command == crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then Protocol.parseParameterInfoMessage(data) if #Protocol.loadQueue > 0 then Protocol.fieldTimeout = 0 elseif Protocol.fieldPopup then Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout end - elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then + elseif command == crsf.CONST.FRAMETYPE_PARAMETER_WRITE then Protocol.parseElrsV1Message(data) - elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then + elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then Protocol.parseElrsInfoMessage(data) end until command == nil @@ -788,16 +717,16 @@ function Protocol.tick() end if Protocol.fieldPopup then - if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY } + if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= crsf.CONST.CMD_ASKCONFIRM then + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY } ) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end elseif time > Protocol.linkstatTimeout then if Protocol.deviceIsELRS_TX then - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) else Protocol.receivedPackets = nil Protocol.lostPackets = nil @@ -805,8 +734,8 @@ function Protocol.tick() Protocol.linkstatTimeout = time + 100 elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then if #Protocol.loadQueue > 0 then - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_READ, + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_READ, { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk } ) Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua b/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua deleted file mode 100644 index fcd585b..0000000 --- a/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua +++ /dev/null @@ -1,27 +0,0 @@ ----- ######################################################################### ----- # Compat layer for BW radios missing standard Lua library functions # ----- ######################################################################### - -local shim = {} - -if table and table.concat then - shim.tableConcat = table.concat -else - shim.tableConcat = function(t, sep, i, j) - i = i or 1 - j = j or #t - if i > j then - return "" - end - local r = t[i] or "" - for k = i + 1, j do - if sep then - r = r .. sep - end - r = r .. (t[k] or "") - end - return r - end -end - -return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 4e72438..f599a4d 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -8,6 +8,7 @@ local deps = ... local App = deps.App local Navigation = deps.Navigation local Protocol = deps.Protocol +local crsf = deps.crsf local VERSION = deps.VERSION local VERSION_CHECK_ENABLED = true @@ -169,14 +170,14 @@ function UI.render(event, _touchState) -- Warning flashing timer local time = getTime() if time > UI.titleShowWarnTimeout then - UI.titleShowWarn = (Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarn = (Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil UI.titleShowWarnTimeout = time + 100 UI.forceRedraw = true end -- Warning dismissal cooldown (60s before re-showing) if UI.warningDismissedAt then - if Protocol.elrsFlags <= Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if Protocol.elrsFlags <= crsf.CONST.ELRS_FLAGS_STATUS_MASK then if time - UI.warningDismissedAt > 6000 then UI.warningDismissed = false UI.warningDismissedAt = nil @@ -294,7 +295,7 @@ function UI.buildVisibleFields() if currentFolder == Navigation.FOLDER_OTHER_DEVICES then for _, device in ipairs(Protocol.devices) do if device.id ~= Protocol.deviceId then - vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.CRSF.DEVICE } + vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.DEVICE } end end else @@ -306,7 +307,7 @@ function UI.buildVisibleFields() end if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then - vf[#vf + 1] = { name = "Other Devices", type = Protocol.CRSF.DEVICE_FOLDER } + vf[#vf + 1] = { name = "Other Devices", type = Protocol.DEVICE_FOLDER } end end @@ -353,11 +354,11 @@ function UI.incrField(step) return end local min, max = 0, 0 - if field.type <= Protocol.CRSF.FLOAT then + if field.type <= crsf.CONST.FIELD_FLOAT then min = field.min or 0 max = field.max or 0 step = (field.step or 1) * step - elseif field.type == Protocol.CRSF.TEXT_SELECTION then + elseif field.type == crsf.CONST.FIELD_TEXT_SELECTION then min = 0 max = #field.values - 1 end @@ -442,18 +443,18 @@ local function fieldCommandDisplay(field, y, attr) end local displayHandlers = {} -displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay -displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay -displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay -displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay -displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay -displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay -displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay -displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay -displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay +displayHandlers[crsf.CONST.FIELD_UINT8] = fieldIntDisplay +displayHandlers[crsf.CONST.FIELD_INT8] = fieldIntDisplay +displayHandlers[crsf.CONST.FIELD_UINT16] = fieldIntDisplay +displayHandlers[crsf.CONST.FIELD_INT16] = fieldIntDisplay +displayHandlers[crsf.CONST.FIELD_FLOAT] = fieldFloatDisplay +displayHandlers[crsf.CONST.FIELD_TEXT_SELECTION] = fieldTextSelDisplay +displayHandlers[crsf.CONST.FIELD_STRING] = fieldStringDisplay +displayHandlers[crsf.CONST.FIELD_INFO] = fieldStringDisplay +displayHandlers[crsf.CONST.FIELD_FOLDER] = fieldFolderDisplay +displayHandlers[crsf.CONST.FIELD_COMMAND] = fieldCommandDisplay +displayHandlers[Protocol.DEVICE] = fieldCommandDisplay +displayHandlers[Protocol.DEVICE_FOLDER] = fieldFolderDisplay -- ============================================================================ -- Title bar drawing @@ -516,9 +517,9 @@ function UI.handleEvent(event) UI.handleBack() end elseif event == EVT_VIRTUAL_ENTER then - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then Protocol.elrsFlags = 0 - Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) elseif UI.isOnBackExit() then if Navigation.isAtRoot() then App.shouldExit = true @@ -530,15 +531,15 @@ function UI.handleEvent(event) if field and field.name then local ft = field.type - if ft == Protocol.CRSF.FOLDER then + if ft == crsf.CONST.FIELD_FOLDER then UI.openFolder(field.id, field.name) - elseif ft == Protocol.CRSF.DEVICE_FOLDER then + elseif ft == Protocol.DEVICE_FOLDER then UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") - elseif ft == Protocol.CRSF.DEVICE then + elseif ft == Protocol.DEVICE then UI.switchDevice(field.id) - elseif ft == Protocol.CRSF.COMMAND then + elseif ft == crsf.CONST.FIELD_COMMAND then Protocol.handleCommandSave(field) - elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then + elseif not field.disabled and ft <= crsf.CONST.FIELD_TEXT_SELECTION then UI.edit = not UI.edit if not UI.edit then Protocol.fieldIntSave(field) @@ -572,7 +573,7 @@ function UI.drawPage(event) lcd.clear() UI.drawTitle() - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then UI.drawWarning() else local totalCount = UI.getSelectableCount() @@ -591,7 +592,7 @@ function UI.drawPage(event) local field = UI.getField(idx) if field and field.name then local ft = field.type - if ft < Protocol.CRSF.FOLDER or ft == Protocol.CRSF.INFO then + if ft < crsf.CONST.FIELD_FOLDER or ft == crsf.CONST.FIELD_INFO then lcd.drawText(UI.COL1, yPos, field.name, 0) end local displayFn = displayHandlers[ft] @@ -610,14 +611,14 @@ end function UI.drawPopup(event) if event == EVT_VIRTUAL_EXIT then - Protocol.push( - Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL } ) Protocol.fieldTimeout = getTime() + 200 end - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if Protocol.fieldPopup.status == crsf.CONST.CMD_ASKCONFIRM then local result = popupConfirmation(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "OK" then @@ -625,7 +626,7 @@ function UI.drawPopup(event) elseif result == "CANCEL" then Protocol.fieldPopup = nil end - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then if Protocol.fieldChunk == 0 then UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 245b530..dd4b46f 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -8,6 +8,7 @@ local deps = ... local App = deps.App local Navigation = deps.Navigation local Protocol = deps.Protocol +local crsf = deps.crsf local VERSION = deps.VERSION local VERSION_CHECK_ENABLED = true @@ -545,8 +546,8 @@ local function handleCommandPopup() return end - if Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then - if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then + if Protocol.fieldPopup.status == crsf.CONST.CMD_ASKCONFIRM then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= crsf.CONST.CMD_ASKCONFIRM then local field = Protocol.fieldPopup UI.commandDialog = CommandPage.showConfirm(field.name, function() return field.info or "" @@ -555,8 +556,8 @@ local function handleCommandPopup() end, onCommandCancel) end Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then - if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then + elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= crsf.CONST.CMD_EXECUTING then local field = Protocol.fieldPopup UI.commandDialog = CommandPage.showExecuting(field.name, function() return field.info or "" @@ -574,7 +575,7 @@ local function handleWarning() if App.shouldExit then return end - if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then if not UI.warningDialog and not UI.warningDismissed then if Protocol.isModelMismatch() then UI.warningDialog = ModelMismatchDialog.show(function() @@ -664,7 +665,7 @@ function UI.getSubtitle() end if - Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK + Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and Protocol.elrsFlagsInfo and Protocol.elrsFlagsInfo ~= "" then @@ -810,7 +811,7 @@ function UI.createChoiceRow(pg, field) end function UI.createNumberRow(pg, field) - local isFloat = field.type == Protocol.CRSF.FLOAT + local isFloat = field.type == crsf.CONST.FIELD_FLOAT local numberEdit = { type = lvgl.NUMBER_EDIT, min = field.min or 0, @@ -975,19 +976,19 @@ function UI.buildFieldWidget(pg, field, folderWidth) local fieldType = field.type - if fieldType == Protocol.CRSF.FOLDER then + if fieldType == crsf.CONST.FIELD_FOLDER then return UI.createFolderWidget(pg, field, folderWidth) end - if fieldType == Protocol.CRSF.COMMAND then + if fieldType == crsf.CONST.FIELD_COMMAND then return UI.createCommandWidget(pg, field) end - if fieldType <= Protocol.CRSF.INT16 or fieldType == Protocol.CRSF.FLOAT then + if fieldType <= crsf.CONST.FIELD_INT16 or fieldType == crsf.CONST.FIELD_FLOAT then return UI.createNumberRow(pg, field) end - if fieldType == Protocol.CRSF.TEXT_SELECTION then + if fieldType == crsf.CONST.FIELD_TEXT_SELECTION then if UI.isBooleanField(field) then return UI.createToggleRow(pg, field) else @@ -995,11 +996,11 @@ function UI.buildFieldWidget(pg, field, folderWidth) end end - if fieldType == Protocol.CRSF.STRING then + if fieldType == crsf.CONST.FIELD_STRING then return UI.createStringRow(pg, field) end - if fieldType == Protocol.CRSF.INFO then + if fieldType == crsf.CONST.FIELD_INFO then return UI.createInfoRow(pg, field) end end @@ -1071,9 +1072,9 @@ function UI.build() while i <= #fieldsInFolder do local field = fieldsInFolder[i] - if field.type == Protocol.CRSF.FOLDER then + if field.type == crsf.CONST.FIELD_FOLDER then local folderBatch = {} - while i <= #fieldsInFolder and fieldsInFolder[i].type == Protocol.CRSF.FOLDER do + while i <= #fieldsInFolder and fieldsInFolder[i].type == crsf.CONST.FIELD_FOLDER do folderBatch[#folderBatch + 1] = fieldsInFolder[i] i = i + 1 end diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index 4223942..fa882e9 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -4,7 +4,7 @@ -- Battery, Current, GPS, and Flight Mode. -- -- -- -- Uses the loadable.lua pattern to minimize memory when not in use. -- --- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +-- Requires /SCRIPTS/ELRS on the SD card for shared CRSF protocol. -- --------------------------------------------------------------------------- local name = "ELRSTelemetry" diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index b5749fd..95e344e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -4,7 +4,7 @@ -- (full-screen) via the CRSF config protocol to the ELRS TX module. -- -- -- -- Uses the loadable.lua pattern to minimize memory when not in use. -- --- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +-- Requires /SCRIPTS/ELRS on the SD card for shared CRSF protocol. -- --------------------------------------------------------------------------- local name = "ELRSVTXAdmin" From c17bdf3c1faf4d3073e76897341d222290a2420c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 11 Aug 2026 13:59:04 +0300 Subject: [PATCH 146/218] Repaint the B&W page on the frame the load queue empties --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index f599a4d..6275756 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -51,6 +51,7 @@ local UI = { -- Redraw state forceRedraw = true, folderWasReady = false, + wasLoading = false, -- Warning flashing titleShowWarn = nil, @@ -208,10 +209,15 @@ function UI.render(event, _touchState) return end - -- Force redraw during loading to show progress bar - if #Protocol.loadQueue > 0 then + -- Force redraw while the queue is loading, to show the progress bar, and once + -- more on the frame it empties. poll() pops the last entry before we get here, + -- so without the trailing edge the response that completes a reload never + -- reaches the screen and the page waits for the next event or warn tick. + local loading = #Protocol.loadQueue > 0 + if loading or UI.wasLoading then UI.forceRedraw = true end + UI.wasLoading = loading -- Render: command popup or normal page if Protocol.fieldPopup ~= nil then From 96a13102994527bd5c66abf90e4156e6ca227592 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 11 Aug 2026 13:59:04 +0300 Subject: [PATCH 147/218] Drop the folder-ready check the LVGL UI can never reach --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index dd4b46f..c38ced3 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -622,14 +622,6 @@ function UI.render(_event, _touchState) if not UI.commandDialog then handleWarning() - local currentFolder = Navigation.getCurrent() - local folderReady = Protocol.isFolderLoaded(currentFolder) - if folderReady and not UI.folderWasReady then - if UI.uiBuilt then - UI.invalidate() - end - end - if not UI.uiBuilt and #Protocol.fields > 0 then UI.build() end From d4f0b0918eec800d23c48ff646c10c01f2ed35b5 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 11 Aug 2026 21:22:11 +0300 Subject: [PATCH 148/218] Stop the widgets' unconditional CRSF polling hasTelemetry now derives from RQly instead of a 1 Hz status poll, and the ELRS status request is sent once per connection (connected + ELRS serial + active-antenna RSSI above -70 dBm), just for model match. The device ping goes to the TX module instead of broadcast -- answered on the UART, never over the air -- via the shared CRSF:pingDevices(dest). VTX Admin loses its periodic folder poll entirely: reads happen at discovery, after our own writes (retried up to 3x), and once on resume from a tool session. Net widget traffic is ~2 frames per session where it was 1-2 per second forever. The CRSF mock honors ping destinations, logs every push, and gains weak_link and mismatch_cycle scenarios; model_mismatch RSSI is raised to a bench-realistic -55/-58 so the status-request gate can pass. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 86 ++++++++++++++-- src/SCRIPTS/ELRS/crsf.lua | 107 +++++++++++++++++--- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 13 +-- src/WIDGETS/ELRSTelemetry/loadable.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 50 +++++++-- 6 files changed, 217 insertions(+), 43 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 9f93c45..12e1407 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -28,8 +28,17 @@ local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -- reconnect without restarting the script. -- "model_mismatch" TX + RX connected but with Model ID mismatch flag set. -- Triggers the Model Mismatch warning dialog. +-- "mismatch_cycle" Model-mismatch link that drops and returns (~10 s up, +-- ~5 s down, forever). Exercises the per-connection status +-- poll latch: one ELRS_STATUS request per connected phase, +-- and the mismatch warning must clear while the link is down. +-- "weak_link" TX + RX connected on a marginal link (RQly ~60, RSSI +-- ~-85 dBm). Both the model-match poll and the VTX Admin +-- folder poll must stay quiet here. -- "armed" TX + RX connected with the "is Armed" warning flag set. -- Shows armed warning in subtitle. +-- "single_antenna" RX with a single RF path: 2RSS pinned to 0, so the +-- widgets report no diversity. -- "slow_loading" TX + RX connected but PARAMETER_READ responses are -- delayed by ~2 seconds each. Tests how the UI renders -- during slow field discovery (e.g. "Loading..." states @@ -1006,11 +1015,16 @@ end local function getElrsFlags() if config.scenario == "reconnect" then return isRxAvailable() and 0x01 or 0x00 - elseif config.scenario == "model_mismatch" then + elseif config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then return 0x09 -- connected + armed - elseif config.scenario == "normal" or config.scenario == "slow_loading" or config.scenario == "single_antenna" then + elseif + config.scenario == "normal" + or config.scenario == "slow_loading" + or config.scenario == "single_antenna" + or config.scenario == "weak_link" + then return 0x01 -- connected else return 0x00 -- no telemetry @@ -1018,7 +1032,7 @@ local function getElrsFlags() end local function getElrsFlagsInfo() - if config.scenario == "model_mismatch" then + if config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then return "Model Mismatch" elseif config.scenario == "armed" then return "[ ! Armed ! ]" @@ -1109,17 +1123,34 @@ local function mockPush(command, data) startTime = getTime() end + -- One line per pushed frame, so a scenario run's wire traffic can be counted + -- from the log (steady-state silence is an empty grep). + print(shim.tableConcat({ + "CRSFSIM push t=", + getTime(), + " cmd=", + command, + " dst=", + data and data[1] or "-", + " field=", + data and data[3] or "-", + })) + if command == CRSF.FRAMETYPE_DEVICE_PING then - local destAddr = data[2] or CRSF.ADDRESS_HANDSET + local dest = data[1] or CRSF.ADDRESS_BROADCAST + local replyTo = data[2] or CRSF.ADDRESS_HANDSET - -- TX module responds immediately (local to handset) - queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, destAddr)) + -- Frames addressed to the TX module are answered on the handset UART and + -- never forwarded over the air, so only a broadcast ping reaches the RX. + if dest == CRSF.ADDRESS_BROADCAST or dest == CRSF.ADDRESS_TX then + queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, replyTo)) + end -- RX device responds with delay (relayed over air link) -- Uses deferred delivery so it arrives in the next poll cycle, -- after the TX DEVICE_INFO has been processed - if isRxAvailable() then - queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, destAddr)) + if dest == CRSF.ADDRESS_BROADCAST and isRxAvailable() then + queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, replyTo)) end return true elseif command == CRSF.FRAMETYPE_PARAMETER_READ then @@ -1374,16 +1405,46 @@ local scenarioTelemetry = { GSpd = 42.7, Alt = 85, }, + -- Bench-realistic signal: a mismatch is caught next to the quad, and the + -- active-antenna RSSI must clear the model-match poll's -70 dBm gate. model_mismatch = { TPWR = 50, RFMD = 7, - ["1RSS"] = -90, - ["2RSS"] = -95, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + ANT = 1, + RxBt = 15.8, + Curr = 0.5, + }, + -- Same signal as model_mismatch; RQly is driven by sensorToggle so the link + -- drops and returns forever (~10 s up, ~5 s down). + mismatch_cycle = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, RQly = 95, ANT = 1, RxBt = 15.8, Curr = 0.5, }, + -- Marginal link: RQly never 0 and never above 90, active-antenna RSSI never + -- above -70 dBm, so every gated poll must stay quiet. + weak_link = { + TPWR = 250, + RFMD = 7, + ["1RSS"] = -85, + ["2RSS"] = -88, + RQly = 60, + ANT = 0, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 9, + GSpd = 31.0, + Alt = 210, + }, reconnect = { -- Same as normal; only served when isRxAvailable() is true TPWR = 50, @@ -1438,6 +1499,11 @@ local sensorToggle = { -- Must stay exactly 0: that is what marks the second RF path as absent. ["2RSS"] = { 0 }, }, + mismatch_cycle = { + -- ~10 s connected, ~5 s down, repeating. Exactly 95 or 0 so the + -- RQly-derived connection state flips cleanly on each phase change. + RQly = { 95, 95, 95, 95, 95, 95, 95, 95, 95, 95, 0, 0, 0, 0, 0 }, + }, } local toggleStep = 0 diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index b3b811f..cf0459c 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -90,7 +90,12 @@ CRSF._handlers = {} -- Device info cache (populated by built-in DEVICE_INFO handler) CRSF.deviceInfo = {} --- Telemetry state (populated by built-in ELRS_STATUS handler) +-- Link state: hasTelemetry is derived from RQly in poll(); the rest comes +-- from the ELRS_STATUS answer updateModelMatch() requests once per connection. +-- elrsFlags/elrsFlagsInfo are therefore a connect-time snapshot: fine for +-- model match, which is decided at connect, but stale for anything dynamic +-- like the armed bit -- read the arm switch via getValue() for that, or poll +-- like the tool does. CRSF.hasTelemetry = false CRSF.modelMismatch = false CRSF.elrsFlags = 0 @@ -105,6 +110,12 @@ CRSF._lastDevPoll = 0 -- ELRS status polling CRSF._lastStatusPoll = 0 +-- Set once the current connection's ELRS_STATUS answer has arrived, so each +-- connection is polled for model match at most once. Cleared with the rest of +-- the per-connection state on any hasTelemetry edge in poll(). +---@type boolean? +CRSF._statusAnswered = nil + -- ============================================================================ -- Default telemetry wrappers: delegate to real EdgeTX functions -- When mocking is active, setMock() replaces these with mock implementations @@ -132,16 +143,22 @@ end CRSF._vCache = {} --- Read a telemetry sensor value by name. --- Caches the getFieldInfo string->ID lookup; getValue is called every time. +-- Caches the getFieldInfo string->ID lookup once it succeeds; a sensor that is +-- not discovered yet is retried on every call, so it starts reading as soon as +-- EdgeTX creates it (e.g. sensor discovery running after the widget loaded). +-- getValue is called every time. -- setMock() replaces this function with the simulator's mock telemetry. function CRSF.getSensorValue(id) local cid = CRSF._vCache[id] if cid == nil then local info = getFieldInfo(id) - cid = info and info.id or 0 + if info == nil then + return nil + end + cid = info.id CRSF._vCache[id] = cid end - return cid ~= 0 and getValue(cid) or nil + return getValue(cid) end -- ============================================================================ @@ -232,6 +249,22 @@ function CRSF:poll() end end end + + -- Connection state is derived from link quality at zero wire cost: the ELRS + -- TX zeroes RQly on disconnect, so a present, positive value is the truth + -- about the link. Runs after the drain so an ELRS_STATUS frame from a dying + -- connection is processed before the edge check, and any edge wipes the + -- per-connection state: a stale modelMismatch can neither survive a + -- disconnect nor suppress the next connection's status poll via a + -- late-arriving answer. + local connected = (CRSF.getSensorValue("RQly") or 0) > 0 + if connected ~= self.hasTelemetry then + self.modelMismatch = false + self.elrsFlags = 0 + self.elrsFlagsInfo = "" + self._statusAnswered = nil + self.hasTelemetry = connected + end end -- ============================================================================ @@ -257,8 +290,22 @@ function CRSF:fieldGetString(data, off) return shim.tableConcat(data, nil, startOff, off - 1), off + 1 end ---- Send a DEVICE_PING if device info is not yet available. --- Rate-limited to at most once per second. +--- Send a DEVICE_PING. +-- A ping addressed to a specific device is answered on the handset UART and +-- never forwarded over the air; a broadcast ping is also forwarded to the RX +-- while the link is up, costing over-the-air round trips. Broadcast only when +-- discovering remote devices. +-- @param dest CRSF device address (use CRSF.CONST.ADDRESS_*); nil broadcasts +function CRSF:pingDevices(dest) + CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { dest or CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_HANDSET }) +end + +--- Ask the TX module for its DEVICE_INFO if it is not cached yet. +-- Addressed to the module itself, so it stays off the air. EdgeTX pings once +-- at module init, but that answer lands before any widget's Lua queue exists +-- (queues are created lazily on the first crossfireTelemetryPop), so widgets +-- must ask themselves. Rate-limited to at most once per second, permanently +-- quiet once answered. function CRSF:requestDeviceInfo() if self.deviceInfo.name then return @@ -268,19 +315,46 @@ function CRSF:requestDeviceInfo() return end self._lastDevPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_HANDSET }) + self:pingDevices(CRSF.CONST.ADDRESS_TX) end --- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). --- Updates hasTelemetry via the ELRS_STATUS handler on the next poll(). --- Rate-limited to at most once per second. +-- The module answers with an ELRS_STATUS frame carrying its warning flags. function CRSF:requestElrsStatus() + CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 }) +end + +--- RSSI of the antenna currently in use, or nil while unknown. +function CRSF.getActiveRssi() + local ant = CRSF.getSensorValue("ANT") + return (ant == 1) and CRSF.getSensorValue("2RSS") or CRSF.getSensorValue("1RSS") +end + +-- Weakest link updateModelMatch() will spend a frame on: a mismatch is caught +-- next to the quad, and on a marginal link every RC-channels frame matters. +local MODEL_MATCH_MIN_RSSI = -70 + +--- Keep modelMismatch current at about one status request per connection. +-- requestElrsStatus() is answered locally, but still replaces one RC-channels +-- frame on the handset->module UART, so it is sent only when it can matter +-- and can be afforded: while connected, from a module that identifies as +-- ExpressLRS (other CRSF modules never answer the fieldId=0 convention), on +-- a strong link, and only until the current connection's answer arrives. +-- Retries at most once per second while unanswered. +function CRSF:updateModelMatch() + if not (self.hasTelemetry and self.deviceInfo.isElrs) or self._statusAnswered then + return + end + local rssi = CRSF.getActiveRssi() + if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then + return + end local now = getTime() - if now - (self._lastStatusPoll or 0) < 100 then + if now - self._lastStatusPoll < 100 then return end self._lastStatusPoll = now - CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 }) + self:requestElrsStatus() end -- ============================================================================ @@ -308,6 +382,13 @@ local function onDeviceInfo(data) info.vRev = vRev info.vStr = string.format("%s (%d.%d.%d)", name, vMaj, vMin, vRev) + -- Serial number "ELRS" identifies an ExpressLRS module. Other CRSF modules + -- answer DEVICE_PING too, but only ELRS answers the fieldId=0 status request. + local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3] + if serial == CRSF.CONST.ELRS_SERIAL_ID then + info.isElrs = true + end + -- RFMOD / RFRSSI lookup tables (version-dependent) if info.vMaj == 4 then -- selene: allow(mixed_table) @@ -426,14 +507,14 @@ local function onDeviceInfo(data) end end --- ELRS_STATUS handler: updates hasTelemetry, modelMismatch, elrsFlagsInfo +-- ELRS_STATUS handler: latches the answer, updates modelMismatch and elrsFlagsInfo local function onElrsStatus(data) if data[2] ~= CRSF.CONST.ADDRESS_TX then return end + CRSF._statusAnswered = true CRSF.elrsFlags = data[6] or 0 - CRSF.hasTelemetry = bit32.btest(CRSF.elrsFlags, 1) CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) -- Null-terminated warning info string starts at data[7] diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 7de33d8..28c06fb 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -91,7 +91,7 @@ function App.reloadAtRoot() Protocol.allocateFields() Protocol.reloadAllFields() end - Protocol.pingDevices() + crsf:pingDevices() end -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 339042a..92116cf 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -88,15 +88,6 @@ function Protocol.reset() Protocol.hadTelemetry = false end --- ============================================================================ --- Transport: the shared CRSF library (crsf.pop/push are rebound by its mock --- in the simulator, so always call through the crsf table) --- ============================================================================ - -function Protocol.pingDevices() - crsf.push(crsf.CONST.FRAMETYPE_DEVICE_PING, { crsf.CONST.ADDRESS_BROADCAST, crsf.CONST.ADDRESS_HANDSET }) -end - -- Check if telemetry is being received from the RX (elrsFlags bit 1) function Protocol.hasTelemetry() return bit32.btest(Protocol.elrsFlags, 1) @@ -705,14 +696,14 @@ function Protocol.tick() -- Ping on telemetry transition (device may have changed) local hasTelemetry = Protocol.hasTelemetry() if hasTelemetry and not Protocol.hadTelemetry then - Protocol.pingDevices() + crsf:pingDevices() end Protocol.hadTelemetry = hasTelemetry local time = getTime() -- Periodic ping for initial device discovery if #Protocol.devices == 0 and time > Protocol.pingTimeout then - Protocol.pingDevices() + crsf:pingDevices() Protocol.pingTimeout = time + 100 -- 1s end diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 4d3eb21..213eaae 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -671,7 +671,7 @@ local wgt = { function wgt.background() crsf:poll() crsf:requestDeviceInfo() - crsf:requestElrsStatus() + crsf:updateModelMatch() Telemetry.update() end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index f036443..fbeed02 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -156,9 +156,18 @@ Protocol = { writeIdx = 0, lastWriteTime = 0, - -- Folder re-read timer - lastFolderPoll = 0, - FOLDER_POLL_INTERVAL = 200, -- 2 seconds + -- Folder re-read: event-driven, never periodic. armFolderRead() sets the + -- deadline after our own writes and on resume from suspension; the folder + -- response clears it, and an unanswered read retries a bounded number of + -- times so a lost frame cannot leave the display stale. + ---@type number? + folderReadPending = nil, + folderReadAttempts = 0, + FOLDER_READ_RETRIES = 3, + + -- Previous tick timestamp, to detect suspension: a standalone tool pauses + -- widget scripts, and whatever it changed needs one read-back on resume. + lastTick = 0, } -- State query helpers @@ -186,6 +195,16 @@ function Protocol.fieldResponseTimeout() return 50 end +--- Arm a folder re-read `delay` ticks from now (0 = next tick). +--- Every push replaces one RC-channels frame on the handset->module UART, so +--- the folder is only ever read when something can have changed it: after our +--- own writes, and once on resume from suspension. Sibling widget instances +--- need nothing — every instance's onSettingsEntry sees every response. +function Protocol.armFolderRead(delay) + Protocol.folderReadPending = getTime() + delay + Protocol.folderReadAttempts = 0 +end + -- ============================================================================ -- Protocol: Parse helpers -- ============================================================================ @@ -309,6 +328,7 @@ function Protocol.onSettingsEntry(data) end elseif st == Protocol.STATE_READY then if fieldId == VTX.ids.folder then + Protocol.folderReadPending = nil VTX.parseFolderName(fieldName) end end @@ -323,6 +343,15 @@ crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, Protocol.onS function Protocol.tick() local now = getTime() + + -- A tick gap over a second means the widget was suspended — a standalone + -- tool had the screen and may have changed the module config — so read the + -- folder back once on resume. + if Protocol.lastTick > 0 and now - Protocol.lastTick > 100 and Protocol.state == Protocol.STATE_READY then + Protocol.armFolderRead(0) + end + Protocol.lastTick = now + local st = Protocol.state if st == Protocol.STATE_INIT then @@ -346,9 +375,14 @@ function Protocol.tick() Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() end elseif st == Protocol.STATE_READY then - if now - Protocol.lastFolderPoll >= Protocol.FOLDER_POLL_INTERVAL then - Protocol.lastFolderPoll = now - Protocol.sendParameterRead(VTX.ids.folder) + if Protocol.folderReadPending and now >= Protocol.folderReadPending then + if Protocol.folderReadAttempts < Protocol.FOLDER_READ_RETRIES then + Protocol.folderReadAttempts = Protocol.folderReadAttempts + 1 + Protocol.folderReadPending = now + Protocol.fieldResponseTimeout() + Protocol.sendParameterRead(VTX.ids.folder) + else + Protocol.folderReadPending = nil + end end elseif st == Protocol.STATE_SENDING then if Protocol.writeIdx <= #Protocol.writeQueue then @@ -364,7 +398,9 @@ function Protocol.tick() Protocol.writeQueue = {} Protocol.writeIdx = 0 Protocol.state = Protocol.STATE_READY - Protocol.lastFolderPoll = now - Protocol.FOLDER_POLL_INTERVAL + 10 + -- Read the folder back ~100ms after the last write so the module has + -- applied the change; the simulator defers folder-name updates ~20ms. + Protocol.armFolderRead(10) end end end From bdeea3d1a76cf2c36b4e4f0f7e9a6b33b4786dcd Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 10:12:46 +0300 Subject: [PATCH 149/218] Remove dead code across the tool, CRSF library and VTX widget App.reset() and Protocol.reset() had no callers and never did -- both arrived orphaned in the BW/colour unification commit. CRSF's unregisterHandler() goes with them, along with registerHandler's duplicate-registration guard: every registration site is a top-level once-per-chunk statement, and the one repeat path (both widgets sharing _crsfSingleton) deliberately registers a fresh closure per instance, so the guard could never match. onDeviceInfo's info.vStr was a string.format on every DEVICE_INFO frame that nothing read. Protocol.handlers loses its constructor placeholder and its save column, which no caller ever dereferenced -- both UIs call fieldIntSave, fieldStringSave and handleCommandSave directly, from code that has already branched on the field type. Entries are plain load functions now; the four typed nil entries stay, documenting the wire types with no handler. The data[i] = nil writes in parseParameterInfoMessage went too: the frame table is fresh per pop and the tool is its only consumer. VERSION_CHECK_ENABLED was constant true in both UIs, so init() runs the version check unconditionally and preCheck tests the result. lvgl's buildFieldWidget drops its folderWidth parameter and FIELD_FOLDER branch, unreachable because the build loop consumes every folder run before reaching it. lcd's init() stops re-assigning its declared defaults, and the count == 0 guards in selectField and handleEvent are gone -- getSelectableCount() is getFieldCount() + 1 for the always-present BACK/EXIT row. warningDismissed folds into warningDismissedAt in both UIs; the boolean equalled "timestamp is set" at every observable point. The lvgl Exit callback's flag-only write is dropped rather than timestamped, since handleWarning early-returns on App.shouldExit and never read it. ELRSVTXAdmin stops passing crsf, Presets and Protocol to the screen files, which never used them, and the orphaned Protocol and VTX locals go with them -- portrait keeps VTX, being the one tier that renders power and pit state. writeConfig's boolean pitmode coercion is unreachable: d.pitmode is a number on every path. Verified in the simulator on TX16S and GX12 -- the tool loads and renders, folder navigation and a field edit work, and the model-mismatch warning shows, dismisses, stays suppressed at 30 s and returns after 60 s. --- src/SCRIPTS/ELRS/crsf.lua | 23 -------- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 7 --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 64 +++++------------------ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 40 +++----------- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 32 +++--------- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 6 --- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 1 - src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 2 - src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 2 - 11 files changed, 28 insertions(+), 153 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index cf0459c..e68cc99 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -198,31 +198,9 @@ function CRSF:registerHandler(frameType, callback) if not self._handlers[frameType] then self._handlers[frameType] = {} end - -- Avoid duplicate registration - for _, cb in ipairs(self._handlers[frameType]) do - if cb == callback then - return - end - end self._handlers[frameType][#self._handlers[frameType] + 1] = callback end ---- Remove a previously registered callback. --- @param frameType numeric frame type --- @param callback the exact function reference to remove -function CRSF:unregisterHandler(frameType, callback) - local handlers = self._handlers[frameType] - if not handlers then - return - end - for i = #handlers, 1, -1 do - if handlers[i] == callback then - shim.tableRemove(handlers, i) - return - end - end -end - -- ============================================================================ -- Pop queue dispatcher -- ============================================================================ @@ -380,7 +358,6 @@ local function onDeviceInfo(data) info.vMaj = vMaj info.vMin = vMin info.vRev = vRev - info.vStr = string.format("%s (%d.%d.%d)", name, vMaj, vMin, vRev) -- Serial number "ELRS" identifies an ExpressLRS module. Other CRSF modules -- answer DEVICE_PING too, but only ELRS answers the fieldId=0 status request. diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 28c06fb..208e415 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -30,13 +30,6 @@ local App = { shouldExit = false, } -function App.reset() - App.crsfModuleChecked = false - App.crsfModuleFound = false - App.shouldExit = false - Protocol.reset() -end - function App.checkCrsfModule() if App.crsfModuleChecked then return App.crsfModuleFound diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 92116cf..c51fd68 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -14,9 +14,6 @@ local Protocol = { DEVICE = 128, DEVICE_FOLDER = 129, - -- Handlers dispatch table (populated after function definitions) - handlers = {}, - -- Device identity (used in every CRSF frame) deviceId = crsf.CONST.ADDRESS_TX, handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, @@ -54,40 +51,6 @@ local Protocol = { hadTelemetry = false, } --- ============================================================================ --- Reset --- ============================================================================ - -function Protocol.reset() - Protocol.deviceId = crsf.CONST.ADDRESS_TX - Protocol.handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS - Protocol.deviceName = nil - Protocol.deviceIsELRS_TX = nil - - Protocol.fields = {} - Protocol.fieldsCount = 0 - Protocol.fieldPopup = nil - - Protocol.devices = {} - - Protocol.elrsFlags = 0 - Protocol.elrsFlagsInfo = "" - Protocol.elrsV1Detected = false - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - - Protocol.linkstatTimeout = 100 - Protocol.pingTimeout = 0 - - Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil - Protocol.loadQueue = {} - Protocol.expectChunksRemain = -1 - Protocol.backgroundLoading = false - Protocol.hadTelemetry = false -end - -- Check if telemetry is being received from the RX (elrsFlags bit 1) function Protocol.hasTelemetry() return bit32.btest(Protocol.elrsFlags, 1) @@ -520,20 +483,20 @@ end -- ============================================================================ Protocol.handlers = { - [crsf.CONST.FIELD_UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [crsf.CONST.FIELD_INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [crsf.CONST.FIELD_UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, - [crsf.CONST.FIELD_INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [crsf.CONST.FIELD_UINT8 + 1] = Protocol.fieldIntLoad, + [crsf.CONST.FIELD_INT8 + 1] = Protocol.fieldIntLoad, + [crsf.CONST.FIELD_UINT16 + 1] = Protocol.fieldIntLoad, + [crsf.CONST.FIELD_INT16 + 1] = Protocol.fieldIntLoad, [crsf.CONST.FIELD_UINT32 + 1] = nil, [crsf.CONST.FIELD_INT32 + 1] = nil, [crsf.CONST.FIELD_UINT64 + 1] = nil, [crsf.CONST.FIELD_INT64 + 1] = nil, - [crsf.CONST.FIELD_FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, - [crsf.CONST.FIELD_TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, - [crsf.CONST.FIELD_STRING + 1] = { load = Protocol.fieldStringLoad, save = Protocol.fieldStringSave }, - [crsf.CONST.FIELD_FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, - [crsf.CONST.FIELD_INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, - [crsf.CONST.FIELD_COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, + [crsf.CONST.FIELD_FLOAT + 1] = Protocol.fieldFloatLoad, + [crsf.CONST.FIELD_TEXT_SELECTION + 1] = Protocol.fieldTextSelLoad, + [crsf.CONST.FIELD_STRING + 1] = Protocol.fieldStringLoad, + [crsf.CONST.FIELD_FOLDER + 1] = Protocol.fieldFolderLoad, + [crsf.CONST.FIELD_INFO + 1] = Protocol.fieldStringLoad, + [crsf.CONST.FIELD_COMMAND + 1] = Protocol.fieldCommandLoad, } -- ============================================================================ @@ -578,7 +541,6 @@ function Protocol.parseParameterInfoMessage(data) Protocol.fieldData = Protocol.fieldData or {} for i = 5, #data do Protocol.fieldData[#Protocol.fieldData + 1] = data[i] - data[i] = nil end offset = 1 else @@ -611,9 +573,9 @@ function Protocol.parseParameterInfoMessage(data) field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) field.nameStale = nil field.reloading = nil - local handler = Protocol.handlers[field.type + 1] - if handler and handler.load then - handler.load(field, Protocol.fieldData, offset) + local load = Protocol.handlers[field.type + 1] + if load then + load(field, Protocol.fieldData, offset) end if field.min == 0 then field.min = nil diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 6275756..79273d7 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -11,7 +11,6 @@ local Protocol = deps.Protocol local crsf = deps.crsf local VERSION = deps.VERSION -local VERSION_CHECK_ENABLED = true local versionCheckResult = nil local function checkEdgeTxVersion() @@ -41,7 +40,8 @@ local UI = { -- Visible field list (rebuilt on invalidate) visibleFields = nil, - -- Layout constants (set in UI.init) + -- Layout constants for 128x64; UI.init widens COL2 at 212px wide and + -- raises maxLineIndex at 96px tall COL1 = 0, COL2 = 70, maxLineIndex = 6, @@ -58,7 +58,6 @@ local UI = { titleShowWarnTimeout = 100, -- Warning dismissal (model mismatch) - warningDismissed = false, warningDismissedAt = nil, -- Command popup spinner @@ -72,21 +71,12 @@ local UI = { function UI.init() if LCD_W == 212 then UI.COL2 = 110 - else - UI.COL2 = 70 end if LCD_H == 96 then UI.maxLineIndex = 9 - else - UI.maxLineIndex = 6 end - UI.COL1 = 0 - UI.textYoffset = 3 - UI.textSize = 8 - if VERSION_CHECK_ENABLED then - versionCheckResult = checkEdgeTxVersion() - end + versionCheckResult = checkEdgeTxVersion() end -- ============================================================================ @@ -94,7 +84,7 @@ end -- ============================================================================ function UI.preCheck(event) - if versionCheckResult == false then + if not versionCheckResult then UI.drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", @@ -177,22 +167,13 @@ function UI.render(event, _touchState) end -- Warning dismissal cooldown (60s before re-showing) - if UI.warningDismissedAt then - if Protocol.elrsFlags <= crsf.CONST.ELRS_FLAGS_STATUS_MASK then - if time - UI.warningDismissedAt > 6000 then - UI.warningDismissed = false - UI.warningDismissedAt = nil - end - elseif UI.warningDismissed and time - UI.warningDismissedAt > 6000 then - UI.warningDismissed = false - UI.warningDismissedAt = nil - end + if UI.warningDismissedAt and time - UI.warningDismissedAt > 6000 then + UI.warningDismissedAt = nil end -- Model mismatch alert (full-screen, blocks normal rendering) - if Protocol.isModelMismatch() and not UI.warningDismissed then + if Protocol.isModelMismatch() and not UI.warningDismissedAt then if event == EVT_VIRTUAL_ENTER then - UI.warningDismissed = true UI.warningDismissedAt = getTime() UI.forceRedraw = true return @@ -391,9 +372,6 @@ end function UI.selectField(step) local count = UI.getSelectableCount() - if count == 0 then - return - end local fieldCount = UI.getFieldCount() local newLineIndex = UI.lineIndex repeat @@ -508,10 +486,6 @@ end -- ============================================================================ function UI.handleEvent(event) - if UI.getSelectableCount() == 0 then - return - end - if event == EVT_VIRTUAL_EXIT then if UI.edit then UI.edit = nil diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index c38ced3..61395fd 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -11,8 +11,6 @@ local Protocol = deps.Protocol local crsf = deps.crsf local VERSION = deps.VERSION -local VERSION_CHECK_ENABLED = true - -- ============================================================================ -- UI state -- ============================================================================ @@ -23,7 +21,6 @@ local UI = { folderWasReady = false, -- Warning/command state (LVGL-specific) - warningDismissed = false, warningDismissedAt = nil, warningDialog = nil, commandDialog = nil, @@ -414,9 +411,7 @@ end -- ============================================================================ function UI.init() - if VERSION_CHECK_ENABLED then - versionCheckResult = checkEdgeTxVersion() - end + versionCheckResult = checkEdgeTxVersion() end -- ============================================================================ @@ -424,7 +419,7 @@ end -- ============================================================================ function UI.preCheck(_event) - if versionCheckResult == false then + if not versionCheckResult then if not UI.uiBuilt then showVersionRequired() UI.uiBuilt = true @@ -576,14 +571,12 @@ local function handleWarning() return end if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then - if not UI.warningDialog and not UI.warningDismissed then + if not UI.warningDialog and not UI.warningDismissedAt then if Protocol.isModelMismatch() then UI.warningDialog = ModelMismatchDialog.show(function() - UI.warningDismissed = true UI.warningDismissedAt = getTime() UI.invalidate() end, function() - UI.warningDismissed = true App.shouldExit = true end) elseif Protocol.hasCriticalError() then @@ -592,21 +585,16 @@ local function handleWarning() message = Protocol.elrsFlagsInfo, }) UI.warningDialog = true - UI.warningDismissed = true UI.warningDismissedAt = getTime() end end - if UI.warningDismissed and UI.warningDismissedAt then - if getTime() - UI.warningDismissedAt > 6000 then - UI.warningDismissed = false - UI.warningDismissedAt = nil - UI.warningDialog = nil - end + if UI.warningDismissedAt and getTime() - UI.warningDismissedAt > 6000 then + UI.warningDismissedAt = nil + UI.warningDialog = nil end else UI.warningDialog = nil - if not UI.warningDismissedAt or (getTime() - UI.warningDismissedAt > 6000) then - UI.warningDismissed = false + if UI.warningDismissedAt and getTime() - UI.warningDismissedAt > 6000 then UI.warningDismissedAt = nil end end @@ -961,17 +949,13 @@ function UI.createCommandWidget(pg, field) }) end -function UI.buildFieldWidget(pg, field, folderWidth) +function UI.buildFieldWidget(pg, field) if not field then return end local fieldType = field.type - if fieldType == crsf.CONST.FIELD_FOLDER then - return UI.createFolderWidget(pg, field, folderWidth) - end - if fieldType == crsf.CONST.FIELD_COMMAND then return UI.createCommandWidget(pg, field) end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index fbeed02..bf31277 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -453,9 +453,6 @@ function Protocol.writeConfig() end local desiredPit = d.pitmode - if type(desiredPit) == "boolean" then - desiredPit = desiredPit and 1 or 0 - end local currentPit = s.pitmode and 1 or 0 if desiredPit ~= currentPit then Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.pitmode, desiredPit } @@ -1005,10 +1002,7 @@ end local screenId = getScreenId() local uiPath = table.concat({ "/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ - crsf = crsf, VTX = VTX, - Protocol = Protocol, - Presets = Presets, bgOpacity = bgOpacity, VTXDisplay = VTXDisplay, WidgetLayout = WidgetLayout, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 78a7e4a..05f2c86 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -4,8 +4,6 @@ --------------------------------------------------------------------------- local ctx = ... -local VTX = ctx.VTX -local Protocol = ctx.Protocol local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index fbdc960..30fec59 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -5,7 +5,6 @@ local ctx = ... local VTX = ctx.VTX -local Protocol = ctx.Protocol local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 04568d0..2226fd0 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -4,8 +4,6 @@ --------------------------------------------------------------------------- local ctx = ... -local VTX = ctx.VTX -local Protocol = ctx.Protocol local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 7d044c5..7e339b2 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -4,8 +4,6 @@ --------------------------------------------------------------------------- local ctx = ... -local VTX = ctx.VTX -local Protocol = ctx.Protocol local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 89afc73..f366ec7 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -4,8 +4,6 @@ --------------------------------------------------------------------------- local ctx = ... -local VTX = ctx.VTX -local Protocol = ctx.Protocol local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout From 1d83d010d69221257ebd8158084ddd6963575db1 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 11:56:21 +0300 Subject: [PATCH 150/218] Split TX-module info out of the CRSF library into the telemetry widget crsf.lua is a transport core again: constants, the pop/push fan-out and sensor reads. The DEVICE_INFO cache, the RFMOD/RFRSSI tables and the per-connection model-match latch move to WIDGETS/ELRSTelemetry/txinfo.lua, loaded only by the widget that reads them, so the tool and VTX Admin no longer pay for them. --- src/SCRIPTS/ELRS/crsf.lua | 265 +--------------------- src/WIDGETS/ELRSTelemetry/loadable.lua | 17 +- src/WIDGETS/ELRSTelemetry/main.lua | 7 +- src/WIDGETS/ELRSTelemetry/txinfo.lua | 286 ++++++++++++++++++++++++ src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 4 +- 5 files changed, 310 insertions(+), 269 deletions(-) create mode 100644 src/WIDGETS/ELRSTelemetry/txinfo.lua diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index e68cc99..9099bcf 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -10,8 +10,8 @@ -- registerHandler() and push outgoing frames through CRSF.push(). -- -- -- -- Loaded once via loadScript() from /SCRIPTS/ELRS/crsf.lua. -- --- Returns a table with protocol constants, handler registry, -- --- pop queue dispatcher, and device info cache. -- +-- Returns a table with protocol constants, handler registry, and -- +-- pop queue dispatcher. -- --------------------------------------------------------------------------- local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() @@ -87,35 +87,12 @@ CRSF.CONST = { -- Handler registry: frameType -> { callback1, callback2, ... } CRSF._handlers = {} --- Device info cache (populated by built-in DEVICE_INFO handler) -CRSF.deviceInfo = {} - --- Link state: hasTelemetry is derived from RQly in poll(); the rest comes --- from the ELRS_STATUS answer updateModelMatch() requests once per connection. --- elrsFlags/elrsFlagsInfo are therefore a connect-time snapshot: fine for --- model match, which is decided at connect, but stale for anything dynamic --- like the armed bit -- read the arm switch via getValue() for that, or poll --- like the tool does. +-- Link state: derived from RQly in poll() CRSF.hasTelemetry = false -CRSF.modelMismatch = false -CRSF.elrsFlags = 0 -CRSF.elrsFlagsInfo = "" -- Tick guard for poll() CRSF._lastPollTick = 0 --- Device info polling -CRSF._lastDevPoll = 0 - --- ELRS status polling -CRSF._lastStatusPoll = 0 - --- Set once the current connection's ELRS_STATUS answer has arrived, so each --- connection is polled for model match at most once. Cleared with the rest of --- the per-connection state on any hasTelemetry edge in poll(). ----@type boolean? -CRSF._statusAnswered = nil - -- ============================================================================ -- Default telemetry wrappers: delegate to real EdgeTX functions -- When mocking is active, setMock() replaces these with mock implementations @@ -230,19 +207,11 @@ function CRSF:poll() -- Connection state is derived from link quality at zero wire cost: the ELRS -- TX zeroes RQly on disconnect, so a present, positive value is the truth - -- about the link. Runs after the drain so an ELRS_STATUS frame from a dying - -- connection is processed before the edge check, and any edge wipes the - -- per-connection state: a stale modelMismatch can neither survive a - -- disconnect nor suppress the next connection's status poll via a - -- late-arriving answer. - local connected = (CRSF.getSensorValue("RQly") or 0) > 0 - if connected ~= self.hasTelemetry then - self.modelMismatch = false - self.elrsFlags = 0 - self.elrsFlagsInfo = "" - self._statusAnswered = nil - self.hasTelemetry = connected - end + -- about the link. Derived after the drain so a frame from a dying connection + -- is dispatched before consumers observe the flip; handlers that keep + -- per-connection state key their resets off that ordering (see + -- ELRSTelemetry/txinfo.lua). + self.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 end -- ============================================================================ @@ -278,230 +247,12 @@ function CRSF:pingDevices(dest) CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { dest or CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_HANDSET }) end ---- Ask the TX module for its DEVICE_INFO if it is not cached yet. --- Addressed to the module itself, so it stays off the air. EdgeTX pings once --- at module init, but that answer lands before any widget's Lua queue exists --- (queues are created lazily on the first crossfireTelemetryPop), so widgets --- must ask themselves. Rate-limited to at most once per second, permanently --- quiet once answered. -function CRSF:requestDeviceInfo() - if self.deviceInfo.name then - return - end - local now = getTime() - if now - self._lastDevPoll < 100 then - return - end - self._lastDevPoll = now - self:pingDevices(CRSF.CONST.ADDRESS_TX) -end - --- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). -- The module answers with an ELRS_STATUS frame carrying its warning flags. function CRSF:requestElrsStatus() CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 }) end ---- RSSI of the antenna currently in use, or nil while unknown. -function CRSF.getActiveRssi() - local ant = CRSF.getSensorValue("ANT") - return (ant == 1) and CRSF.getSensorValue("2RSS") or CRSF.getSensorValue("1RSS") -end - --- Weakest link updateModelMatch() will spend a frame on: a mismatch is caught --- next to the quad, and on a marginal link every RC-channels frame matters. -local MODEL_MATCH_MIN_RSSI = -70 - ---- Keep modelMismatch current at about one status request per connection. --- requestElrsStatus() is answered locally, but still replaces one RC-channels --- frame on the handset->module UART, so it is sent only when it can matter --- and can be afforded: while connected, from a module that identifies as --- ExpressLRS (other CRSF modules never answer the fieldId=0 convention), on --- a strong link, and only until the current connection's answer arrives. --- Retries at most once per second while unanswered. -function CRSF:updateModelMatch() - if not (self.hasTelemetry and self.deviceInfo.isElrs) or self._statusAnswered then - return - end - local rssi = CRSF.getActiveRssi() - if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then - return - end - local now = getTime() - if now - self._lastStatusPoll < 100 then - return - end - self._lastStatusPoll = now - self:requestElrsStatus() -end - --- ============================================================================ --- Built-in handlers --- ============================================================================ - --- DEVICE_INFO handler: parses and caches module name, version, RFMOD/RFRSSI -local function onDeviceInfo(data) - if data[2] ~= CRSF.CONST.ADDRESS_TX then - return - end - - local name, off = CRSF:fieldGetString(data, 3) - -- off is the first byte after the name's null terminator: - -- serNo (4) + hwVer (4) + swVer (4), where swVer's low 3 bytes are maj.min.rev - local vMaj, vMin, vRev = data[off + 9], data[off + 10], data[off + 11] - if not vRev then - return -- frame shorter than the layout; leave the cache so the ping retries - end - - local info = CRSF.deviceInfo - info.name = name - info.vMaj = vMaj - info.vMin = vMin - info.vRev = vRev - - -- Serial number "ELRS" identifies an ExpressLRS module. Other CRSF modules - -- answer DEVICE_PING too, but only ELRS answers the fieldId=0 status request. - local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3] - if serial == CRSF.CONST.ELRS_SERIAL_ID then - info.isElrs = true - end - - -- RFMOD / RFRSSI lookup tables (version-dependent) - if info.vMaj == 4 then - -- selene: allow(mixed_table) - info.RFMOD = { - "25Hz", - "50Hz", - "100Hz", - "100HzFull", - "150Hz", - "200Hz", - "200HzFull", - "250Hz", - "333HzFull", - "500Hz", - "D50", - "K1000Full", - [21] = "25Hz", - [22] = "50Hz", - [23] = "100Hz", - [24] = "100HzFull", - [25] = "150Hz", - [26] = "200Hz", - [27] = "200HzFull", - [28] = "250Hz", - [29] = "333HzFull", - [30] = "500Hz", - [31] = "D250", - [32] = "D500", - [33] = "F500", - [34] = "F1000", - [35] = "DK250", - [36] = "DK500", - [37] = "K1000", - [101] = "X100Full", - [102] = "X150", - } - -- selene: allow(mixed_table) - info.RFRSSI = { - -123, - -120, - -117, - -112, - 0, - -112, - -111, - -111, - 0, - 0, - -112, - -101, - [21] = 0, - [22] = -115, - [23] = 0, - [24] = -112, - [25] = -112, - [26] = 0, - [27] = 0, - [28] = -108, - [29] = -105, - [30] = -105, - [31] = -104, - [32] = -104, - [33] = -104, - [34] = -104, - [35] = -103, - [36] = -103, - [37] = -103, - [101] = -112, - [102] = -112, - } - elseif info.vMaj == 3 then - info.RFMOD = { - "", - "25Hz", - "50Hz", - "100Hz", - "100HzFull", - "150Hz", - "200Hz", - "250Hz", - "333HzFull", - "500Hz", - "D250", - "D500", - "F500", - "F1000", - "D50", - "200HzFull", - "DK500", - "K1000", - "9K1000", - "K1000Full", - } - info.RFRSSI = { - 0, - -123, - -115, - -117, - -112, - -112, - -112, - -108, - -105, - -105, - -104, - -104, - -104, - -104, - -112, - -111, - -103, - -103, - 0, - -101, - } - end -end - --- ELRS_STATUS handler: latches the answer, updates modelMismatch and elrsFlagsInfo -local function onElrsStatus(data) - if data[2] ~= CRSF.CONST.ADDRESS_TX then - return - end - - CRSF._statusAnswered = true - CRSF.elrsFlags = data[6] or 0 - CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) - - -- Null-terminated warning info string starts at data[7] - CRSF.elrsFlagsInfo = CRSF:fieldGetString(data, 7) -end - --- Register built-in handlers -CRSF:registerHandler(CRSF.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) -CRSF:registerHandler(CRSF.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) - -- ============================================================================ -- Return singleton -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 213eaae..87b91ce 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -3,12 +3,12 @@ -- Loaded via loadScript() from ELRSTelemetry/main.lua -- -- -- -- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- --- singleton passed from main.lua for device info discovery. -- +-- singleton for transport and txinfo.lua for device info/model match. -- -- -- -- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- --------------------------------------------------------------------------- -local zone, options, crsf = ... +local zone, options, crsf, txinfo = ... -- Forward declarations for modules local Telemetry @@ -91,7 +91,7 @@ end --- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means --- anything while connected: ungated, a stale flag paints a label RED under text reading "--". function Telemetry.isMismatch() - return crsf.hasTelemetry and crsf.modelMismatch + return crsf.hasTelemetry and txinfo.modelMismatch end --- Short status text when not operational or warning active. @@ -104,7 +104,7 @@ function Telemetry.statusText() if not crsf.hasTelemetry then return "No telemetry" end - if crsf.modelMismatch then + if txinfo.modelMismatch then return "Model Mismatch" end return nil @@ -112,7 +112,7 @@ end --- Compute smoothed range percentage from RSSI. function Telemetry.getRangePct(tlm) - local mod = crsf.deviceInfo + local mod = txinfo.deviceInfo local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 if rssi == nil then return 0 @@ -137,7 +137,7 @@ function Telemetry.getRfModeStr(rfmd) if not crsf.hasTelemetry or rfmd == nil then return "" end - local mod = crsf.deviceInfo + local mod = txinfo.deviceInfo return (mod.RFMOD and mod.RFMOD[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) end @@ -458,7 +458,7 @@ local function buildFullScreen() if not crsf.hasTelemetry then return "No telemetry" end - if crsf.modelMismatch then + if txinfo.modelMismatch then return "Model Mismatch" end return "Telemetry" @@ -670,8 +670,7 @@ local wgt = { function wgt.background() crsf:poll() - crsf:requestDeviceInfo() - crsf:updateModelMatch() + txinfo:update() Telemetry.update() end diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index fa882e9..9311af1 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -16,9 +16,14 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end + if not _elrsTxInfoSingleton then + local getTxInfo = loadScript("/WIDGETS/" .. name .. "/txinfo.lua") + ---@diagnostic disable-next-line: need-check-nil + _elrsTxInfoSingleton = getTxInfo(_crsfSingleton) + end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton) + return loadable(zone, options, _crsfSingleton, _elrsTxInfoSingleton) end local function refresh(widget, event, touchState) diff --git a/src/WIDGETS/ELRSTelemetry/txinfo.lua b/src/WIDGETS/ELRSTelemetry/txinfo.lua new file mode 100644 index 0000000..1e4287f --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/txinfo.lua @@ -0,0 +1,286 @@ +--------------------------------------------------------------------------- +-- ELRS TX Module Info -- +-- -- +-- DEVICE_INFO cache (module name, RFMOD/RFRSSI lookup tables) and the -- +-- per-connection model-match status, fed by frame handlers registered -- +-- on the shared CRSF singleton. Loaded once per Lua state by -- +-- ELRSTelemetry/main.lua and shared by every widget instance; widgets -- +-- that do not need this data never load it. -- +-- -- +-- update() is the per-tick pump: call it right after crsf:poll(). -- +--------------------------------------------------------------------------- + +local crsf = ... + +local TxInfo = {} + +-- ============================================================================ +-- State +-- ============================================================================ + +-- Device info cache (populated by the DEVICE_INFO handler): +-- name, isElrs, RFMOD, RFRSSI +TxInfo.deviceInfo = {} + +-- Model match comes from the ELRS_STATUS answer updateModelMatch() requests +-- once per connection: a connect-time snapshot, fine for model match, which +-- is decided at connect. +---@type boolean? +TxInfo.modelMismatch = nil + +-- Device info polling +TxInfo._lastDevPoll = 0 + +-- ELRS status polling +TxInfo._lastStatusPoll = 0 + +-- Set once the current connection's ELRS_STATUS answer has arrived, so each +-- connection is polled for model match at most once. Cleared with the rest of +-- the per-connection state on any hasTelemetry edge in update(). +---@type boolean? +TxInfo._statusAnswered = nil + +-- hasTelemetry as last seen by update(), to detect connection edges. +TxInfo._wasConnected = false + +-- ============================================================================ +-- Helpers +-- ============================================================================ + +--- RSSI of the antenna currently in use, or nil while unknown. +local function getActiveRssi() + local ant = crsf.getSensorValue("ANT") + return (ant == 1) and crsf.getSensorValue("2RSS") or crsf.getSensorValue("1RSS") +end + +-- ============================================================================ +-- Outgoing requests +-- ============================================================================ + +--- Ask the TX module for its DEVICE_INFO if it is not cached yet. +-- Addressed to the module itself, so it stays off the air. EdgeTX pings once +-- at module init, but that answer lands before any widget's Lua queue exists +-- (queues are created lazily on the first crossfireTelemetryPop), so widgets +-- must ask themselves. Rate-limited to at most once per second, permanently +-- quiet once answered. +local function requestDeviceInfo() + if TxInfo.deviceInfo.name then + return + end + local now = getTime() + if now - TxInfo._lastDevPoll < 100 then + return + end + TxInfo._lastDevPoll = now + crsf:pingDevices(crsf.CONST.ADDRESS_TX) +end + +-- Weakest link updateModelMatch() will spend a frame on: a mismatch is caught +-- next to the quad, and on a marginal link every RC-channels frame matters. +local MODEL_MATCH_MIN_RSSI = -70 + +--- Keep modelMismatch current at about one status request per connection. +-- requestElrsStatus() is answered locally, but still replaces one RC-channels +-- frame on the handset->module UART, so it is sent only when it can matter +-- and can be afforded: while connected, from a module that identifies as +-- ExpressLRS (other CRSF modules never answer the fieldId=0 convention), on +-- a strong link, and only until the current connection's answer arrives. +-- Retries at most once per second while unanswered. +local function updateModelMatch() + if not (crsf.hasTelemetry and TxInfo.deviceInfo.isElrs) or TxInfo._statusAnswered then + return + end + local rssi = getActiveRssi() + if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then + return + end + local now = getTime() + if now - TxInfo._lastStatusPoll < 100 then + return + end + TxInfo._lastStatusPoll = now + crsf:requestElrsStatus() +end + +--- Per-tick pump; call right after crsf:poll() so the tick's frames are +-- dispatched and hasTelemetry is current. Any hasTelemetry edge wipes the +-- per-connection state before updateModelMatch() runs: a stale modelMismatch +-- can neither survive a disconnect nor suppress the next connection's status +-- poll via a late-arriving answer. poll() dispatches frames before it derives +-- hasTelemetry, so a dying connection's ELRS_STATUS always lands before the +-- edge is observed here. +function TxInfo:update() + local connected = crsf.hasTelemetry + if connected ~= self._wasConnected then + self._wasConnected = connected + self.modelMismatch = nil + self._statusAnswered = nil + end + requestDeviceInfo() + updateModelMatch() +end + +-- ============================================================================ +-- Frame handlers +-- ============================================================================ + +-- DEVICE_INFO handler: parses and caches module name and RFMOD/RFRSSI +local function onDeviceInfo(data) + if data[2] ~= crsf.CONST.ADDRESS_TX then + return + end + + local name, off = crsf:fieldGetString(data, 3) + -- off is the first byte after the name's null terminator: + -- serNo (4) + hwVer (4) + swVer (4), where swVer's low 3 bytes are maj.min.rev + local vMaj, vRev = data[off + 9], data[off + 11] + if not vRev then + return -- frame shorter than the layout; leave the cache so the ping retries + end + + local info = TxInfo.deviceInfo + info.name = name + + -- Serial number "ELRS" identifies an ExpressLRS module. Other CRSF modules + -- answer DEVICE_PING too, but only ELRS answers the fieldId=0 status request. + local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3] + if serial == crsf.CONST.ELRS_SERIAL_ID then + info.isElrs = true + end + + -- RFMOD / RFRSSI lookup tables (version-dependent) + if vMaj == 4 then + -- selene: allow(mixed_table) + info.RFMOD = { + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "200HzFull", + "250Hz", + "333HzFull", + "500Hz", + "D50", + "K1000Full", + [21] = "25Hz", + [22] = "50Hz", + [23] = "100Hz", + [24] = "100HzFull", + [25] = "150Hz", + [26] = "200Hz", + [27] = "200HzFull", + [28] = "250Hz", + [29] = "333HzFull", + [30] = "500Hz", + [31] = "D250", + [32] = "D500", + [33] = "F500", + [34] = "F1000", + [35] = "DK250", + [36] = "DK500", + [37] = "K1000", + [101] = "X100Full", + [102] = "X150", + } + -- selene: allow(mixed_table) + info.RFRSSI = { + -123, + -120, + -117, + -112, + 0, + -112, + -111, + -111, + 0, + 0, + -112, + -101, + [21] = 0, + [22] = -115, + [23] = 0, + [24] = -112, + [25] = -112, + [26] = 0, + [27] = 0, + [28] = -108, + [29] = -105, + [30] = -105, + [31] = -104, + [32] = -104, + [33] = -104, + [34] = -104, + [35] = -103, + [36] = -103, + [37] = -103, + [101] = -112, + [102] = -112, + } + elseif vMaj == 3 then + info.RFMOD = { + "", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "250Hz", + "333HzFull", + "500Hz", + "D250", + "D500", + "F500", + "F1000", + "D50", + "200HzFull", + "DK500", + "K1000", + "9K1000", + "K1000Full", + } + info.RFRSSI = { + 0, + -123, + -115, + -117, + -112, + -112, + -112, + -108, + -105, + -105, + -104, + -104, + -104, + -104, + -112, + -111, + -103, + -103, + 0, + -101, + } + end +end + +-- ELRS_STATUS handler: latches the answer and updates modelMismatch +local function onElrsStatus(data) + if data[2] ~= crsf.CONST.ADDRESS_TX then + return + end + + TxInfo._statusAnswered = true + TxInfo.modelMismatch = bit32.btest(data[6] or 0, 4) or nil +end + +crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) +crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return TxInfo diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 80ffd5a..97b9e9b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -39,7 +39,7 @@ function TopBarUI.build(w, h) if not crsf.hasTelemetry then return "--" end - if crsf.modelMismatch then + if Telemetry.isMismatch() then return "Model" end return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) @@ -54,7 +54,7 @@ function TopBarUI.build(w, h) if not crsf.hasTelemetry then return "--" end - if crsf.modelMismatch then + if Telemetry.isMismatch() then return "Mismatch" end local rssi = Telemetry.getRssi(Telemetry.link) From 7ab758a644e0350f8e6a5cda4d94a4af82102187 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 11:56:52 +0300 Subject: [PATCH 151/218] Decode ELRS frames in the CRSF library decodeDeviceInfo(), decodeElrsStatus() and isElrsV1Frame() own the wire layout of DEVICE_INFO, ELRS_STATUS and the 1.x signature; callers gate on the decoded source id. The status decode keeps the connected bit alongside modelMismatch and criticalError. The tool's protocol.lua consumes them instead of carrying its own parsers and bit masks, stores the decoded flags, gains a short-frame guard its DEVICE_INFO path lacked, and sends the link-status request through requestElrsStatus(), whose hardcoded addressing the deviceIsELRS_TX gate already guarantees. --- src/SCRIPTS/ELRS/crsf.lua | 63 +++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 76 ++++++++++++++--------- 2 files changed, 108 insertions(+), 31 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 9099bcf..c7f8399 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -237,6 +237,69 @@ function CRSF:fieldGetString(data, off) return shim.tableConcat(data, nil, startOff, off - 1), off + 1 end +--- Decode a DEVICE_INFO (0x29) frame. +-- Payload after [dest, src]: name (null-terminated), serial (4B BE), +-- hwVer (4B), swVer (4B, low three bytes are maj.min.rev), fieldCount (1B), +-- parameter protocol version (1B). Consumes the name bytes in place (see +-- fieldGetString). No address gate: callers gate on the returned id. +-- @param data array of byte values +-- @return table with id (source address), name, isElrs (true/nil), fieldCount, +-- vMaj, vMin, vRev -- or nil if the frame is shorter than the layout +function CRSF:decodeDeviceInfo(data) + local id = data[2] + local name, off = self:fieldGetString(data, 3) + if data[off + 12] == nil then + return nil -- shorter than the fixed layout; the caller's ping retries + end + local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3] + return { + id = id, + name = name, + isElrs = (serial == CRSF.CONST.ELRS_SERIAL_ID) or nil, + fieldCount = data[off + 12], + vMaj = data[off + 9], + vMin = data[off + 10], + vRev = data[off + 11], + } +end + +--- Decode an ELRS_STATUS (0x2E) frame (the answer to requestElrsStatus()). +-- Consumes the warning bytes in place (see fieldGetString). No address gate: +-- callers gate on the returned id. +-- @param data array of byte values +-- @return table with id (source address), lostPackets, receivedPackets, flags +-- (raw byte, for threshold checks), connected / modelMismatch / +-- criticalError (true/nil), warning (always a string, "" when the +-- module sends none) -- or nil if the frame is shorter than the +-- flags byte +function CRSF:decodeElrsStatus(data) + if data[6] == nil then + return nil + end + local flags = data[6] + local warning = self:fieldGetString(data, 7) + return { + id = data[2], + lostPackets = data[3], + receivedPackets = data[4] * 256 + data[5], + flags = flags, + connected = bit32.btest(flags, 1) or nil, + modelMismatch = bit32.btest(flags, 4) or nil, + criticalError = (flags > CRSF.CONST.ELRS_FLAGS_WARNING_THRESHOLD) or nil, + warning = warning, + } +end + +--- ELRS 1.x signature: an inbound PARAMETER_WRITE addressed to the official +-- handset address from the TX module. 3.x+ answers on ADDRESS_HANDSET_ELRS and +-- never writes to the handset. Reads data[1] (the destination) deliberately -- +-- unlike the decoders above, which leave gating on the source to the caller. +-- @param data array of byte values +-- @return true when the frame matches the 1.x signature, nil otherwise +function CRSF:isElrsV1Frame(data) + return (data[1] == CRSF.CONST.ADDRESS_HANDSET and data[2] == CRSF.CONST.ADDRESS_TX) or nil +end + --- Send a DEVICE_PING. -- A ping addressed to a specific device is answered on the handset UART and -- never forwarded over the air; a broadcast ping is also forwarded to the RX diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index c51fd68..143bbb2 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -34,6 +34,9 @@ local Protocol = { elrsV1Detected = false, receivedPackets = nil, lostPackets = nil, + connected = nil, + modelMismatch = nil, + criticalError = nil, -- Protocol timing linkstatTimeout = 100, @@ -51,17 +54,17 @@ local Protocol = { hadTelemetry = false, } --- Check if telemetry is being received from the RX (elrsFlags bit 1) +-- Telemetry is being received from the RX (decoded ELRS status connected bit) function Protocol.hasTelemetry() - return bit32.btest(Protocol.elrsFlags, 1) + return Protocol.connected end function Protocol.isModelMismatch() - return bit32.btest(Protocol.elrsFlags, 0x04) + return Protocol.modelMismatch end function Protocol.hasCriticalError() - return Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD + return Protocol.criticalError end -- Response timeout for PARAMETER_READ: @@ -82,6 +85,9 @@ function Protocol.setDevice(device) Protocol.deviceId = device.id Protocol.elrsFlags = 0 + Protocol.connected = nil + Protocol.modelMismatch = nil + Protocol.criticalError = nil Protocol.deviceName = device.name Protocol.fieldsCount = device.fieldCount Protocol.deviceIsELRS_TX = device.isElrs and device.id == crsf.CONST.ADDRESS_TX or nil @@ -504,17 +510,19 @@ Protocol.handlers = { -- ============================================================================ function Protocol.parseDeviceInfoMessage(data) - local id = data[2] - local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) - local device = Protocol.getDevice(id) + local info = crsf:decodeDeviceInfo(data) + if not info then + return nil + end + local device = Protocol.getDevice(info.id) local isNew = (device == nil) if isNew then - device = { id = id } + device = { id = info.id } Protocol.devices[#Protocol.devices + 1] = device end - device.name = newName - device.fieldCount = data[offset + 12] - device.isElrs = Protocol.fieldGetValue(data, offset, 4) == crsf.CONST.ELRS_SERIAL_ID + device.name = info.name + device.fieldCount = info.fieldCount + device.isElrs = info.isElrs return device, isNew end @@ -598,24 +606,23 @@ function Protocol.parseParameterInfoMessage(data) end function Protocol.parseElrsInfoMessage(data) - if data[2] ~= Protocol.deviceId then + local status = crsf:decodeElrsStatus(data) + if not status then + return + end + if status.id ~= Protocol.deviceId then Protocol.fieldData = nil Protocol.fieldChunk = 0 return end - Protocol.lostPackets = data[3] - Protocol.receivedPackets = (data[4] * 256) + data[5] - local newFlags = data[6] - Protocol.elrsFlags = newFlags - Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) -end - -function Protocol.parseElrsV1Message(data) - if (data[1] ~= crsf.CONST.ADDRESS_HANDSET) or (data[2] ~= crsf.CONST.ADDRESS_TX) then - return - end - Protocol.elrsV1Detected = true + Protocol.lostPackets = status.lostPackets + Protocol.receivedPackets = status.receivedPackets + Protocol.elrsFlags = status.flags + Protocol.connected = status.connected + Protocol.modelMismatch = status.modelMismatch + Protocol.criticalError = status.criticalError + Protocol.elrsFlagsInfo = status.warning end -- ============================================================================ @@ -631,11 +638,13 @@ function Protocol.poll() command, data = crsf.pop() if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then local device, isNew = Protocol.parseDeviceInfoMessage(data) - if device.id == Protocol.deviceId then - targetDevice = device - end - if isNew then - anyNewDevice = true + if device then + if device.id == Protocol.deviceId then + targetDevice = device + end + if isNew then + anyNewDevice = true + end end elseif command == crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then Protocol.parseParameterInfoMessage(data) @@ -645,7 +654,9 @@ function Protocol.poll() Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout end elseif command == crsf.CONST.FRAMETYPE_PARAMETER_WRITE then - Protocol.parseElrsV1Message(data) + if crsf:isElrsV1Frame(data) then + Protocol.elrsV1Detected = true + end elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then Protocol.parseElrsInfoMessage(data) end @@ -679,7 +690,10 @@ function Protocol.tick() end elseif time > Protocol.linkstatTimeout then if Protocol.deviceIsELRS_TX then - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + -- deviceIsELRS_TX guarantees deviceId/handsetId are ADDRESS_TX and + -- ADDRESS_HANDSET_ELRS here (see setDevice), the addressing + -- requestElrsStatus() hardcodes. + crsf:requestElrsStatus() else Protocol.receivedPackets = nil Protocol.lostPackets = nil From a62d4493455d2a564d67b51e1e82346c55ab5c44 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 12:19:42 +0300 Subject: [PATCH 152/218] Serve the TX-module info state from the ELRS library elrsinfo.lua is the opt-in stateful companion to the CRSF singleton -- DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch -- registering its handlers on the shared singleton and consuming the shared decoders instead of parsing frames itself. Loaded only by the telemetry widget, so the tool and VTX Admin never pay for it. The RF tables now build once per detected major version instead of on every DEVICE_INFO frame, and the highest known version at or below vMaj wins, so ELRS 5 firmware keeps the newest rate names instead of losing them. --- src/SCRIPTS/ELRS/crsf.lua | 2 +- .../txinfo.lua => SCRIPTS/ELRS/elrsinfo.lua} | 110 ++++++++++-------- src/WIDGETS/ELRSTelemetry/loadable.lua | 16 +-- src/WIDGETS/ELRSTelemetry/main.lua | 8 +- 4 files changed, 76 insertions(+), 60 deletions(-) rename src/{WIDGETS/ELRSTelemetry/txinfo.lua => SCRIPTS/ELRS/elrsinfo.lua} (73%) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index c7f8399..40a93e0 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -210,7 +210,7 @@ function CRSF:poll() -- about the link. Derived after the drain so a frame from a dying connection -- is dispatched before consumers observe the flip; handlers that keep -- per-connection state key their resets off that ordering (see - -- ELRSTelemetry/txinfo.lua). + -- /SCRIPTS/ELRS/elrsinfo.lua). self.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 end diff --git a/src/WIDGETS/ELRSTelemetry/txinfo.lua b/src/SCRIPTS/ELRS/elrsinfo.lua similarity index 73% rename from src/WIDGETS/ELRSTelemetry/txinfo.lua rename to src/SCRIPTS/ELRS/elrsinfo.lua index 1e4287f..b5a61e8 100644 --- a/src/WIDGETS/ELRSTelemetry/txinfo.lua +++ b/src/SCRIPTS/ELRS/elrsinfo.lua @@ -1,18 +1,19 @@ --------------------------------------------------------------------------- -- ELRS TX Module Info -- -- -- --- DEVICE_INFO cache (module name, RFMOD/RFRSSI lookup tables) and the -- +-- Opt-in stateful companion to the CRSF singleton: DEVICE_INFO cache -- +-- (module name, version-keyed RFMOD/RFRSSI lookup tables) and the -- -- per-connection model-match status, fed by frame handlers registered -- --- on the shared CRSF singleton. Loaded once per Lua state by -- --- ELRSTelemetry/main.lua and shared by every widget instance; widgets -- --- that do not need this data never load it. -- +-- on the shared CRSF singleton. Loaded once per Lua state and shared by -- +-- every widget instance; widgets that do not need this data never load -- +-- it, so they never pay for the tables. -- -- -- -- update() is the per-tick pump: call it right after crsf:poll(). -- --------------------------------------------------------------------------- local crsf = ... -local TxInfo = {} +local ElrsInfo = {} -- ============================================================================ -- State @@ -20,28 +21,33 @@ local TxInfo = {} -- Device info cache (populated by the DEVICE_INFO handler): -- name, isElrs, RFMOD, RFRSSI -TxInfo.deviceInfo = {} +ElrsInfo.deviceInfo = {} -- Model match comes from the ELRS_STATUS answer updateModelMatch() requests -- once per connection: a connect-time snapshot, fine for model match, which -- is decided at connect. ---@type boolean? -TxInfo.modelMismatch = nil +ElrsInfo.modelMismatch = nil -- Device info polling -TxInfo._lastDevPoll = 0 +ElrsInfo._lastDevPoll = 0 -- ELRS status polling -TxInfo._lastStatusPoll = 0 +ElrsInfo._lastStatusPoll = 0 -- Set once the current connection's ELRS_STATUS answer has arrived, so each -- connection is polled for model match at most once. Cleared with the rest of -- the per-connection state on any hasTelemetry edge in update(). ---@type boolean? -TxInfo._statusAnswered = nil +ElrsInfo._statusAnswered = nil -- hasTelemetry as last seen by update(), to detect connection edges. -TxInfo._wasConnected = false +ElrsInfo._wasConnected = false + +-- Effective major version of the RFMOD/RFRSSI tables currently built. The +-- only version datum kept, purely so selectRfTables() can skip rebuilds. +---@type number? +ElrsInfo._rfMaj = nil -- ============================================================================ -- Helpers @@ -64,14 +70,14 @@ end -- must ask themselves. Rate-limited to at most once per second, permanently -- quiet once answered. local function requestDeviceInfo() - if TxInfo.deviceInfo.name then + if ElrsInfo.deviceInfo.name then return end local now = getTime() - if now - TxInfo._lastDevPoll < 100 then + if now - ElrsInfo._lastDevPoll < 100 then return end - TxInfo._lastDevPoll = now + ElrsInfo._lastDevPoll = now crsf:pingDevices(crsf.CONST.ADDRESS_TX) end @@ -87,7 +93,7 @@ local MODEL_MATCH_MIN_RSSI = -70 -- a strong link, and only until the current connection's answer arrives. -- Retries at most once per second while unanswered. local function updateModelMatch() - if not (crsf.hasTelemetry and TxInfo.deviceInfo.isElrs) or TxInfo._statusAnswered then + if not (crsf.hasTelemetry and ElrsInfo.deviceInfo.isElrs) or ElrsInfo._statusAnswered then return end local rssi = getActiveRssi() @@ -95,10 +101,10 @@ local function updateModelMatch() return end local now = getTime() - if now - TxInfo._lastStatusPoll < 100 then + if now - ElrsInfo._lastStatusPoll < 100 then return end - TxInfo._lastStatusPoll = now + ElrsInfo._lastStatusPoll = now crsf:requestElrsStatus() end @@ -109,7 +115,7 @@ end -- poll via a late-arriving answer. poll() dispatches frames before it derives -- hasTelemetry, so a dying connection's ELRS_STATUS always lands before the -- edge is observed here. -function TxInfo:update() +function ElrsInfo:update() local connected = crsf.hasTelemetry if connected ~= self._wasConnected then self._wasConnected = connected @@ -124,32 +130,25 @@ end -- Frame handlers -- ============================================================================ --- DEVICE_INFO handler: parses and caches module name and RFMOD/RFRSSI -local function onDeviceInfo(data) - if data[2] ~= crsf.CONST.ADDRESS_TX then - return - end - - local name, off = crsf:fieldGetString(data, 3) - -- off is the first byte after the name's null terminator: - -- serNo (4) + hwVer (4) + swVer (4), where swVer's low 3 bytes are maj.min.rev - local vMaj, vRev = data[off + 9], data[off + 11] - if not vRev then - return -- frame shorter than the layout; leave the cache so the ping retries +--- Install the RFMOD/RFRSSI lookup tables for an ELRS major version. +-- The highest known version at or below vMaj wins, so newer firmware +-- degrades to the newest known tables instead of losing its rate names. +-- Rebuilt only when the effective version changes (first answer, module +-- swap across reconnects), never per frame. +local function selectRfTables(vMaj) + local effMaj + if vMaj >= 4 then + effMaj = 4 + elseif vMaj == 3 then + effMaj = 3 end - - local info = TxInfo.deviceInfo - info.name = name - - -- Serial number "ELRS" identifies an ExpressLRS module. Other CRSF modules - -- answer DEVICE_PING too, but only ELRS answers the fieldId=0 status request. - local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3] - if serial == crsf.CONST.ELRS_SERIAL_ID then - info.isElrs = true + if ElrsInfo._rfMaj == effMaj then + return end + ElrsInfo._rfMaj = effMaj - -- RFMOD / RFRSSI lookup tables (version-dependent) - if vMaj == 4 then + local info = ElrsInfo.deviceInfo + if effMaj == 4 then -- selene: allow(mixed_table) info.RFMOD = { "25Hz", @@ -218,7 +217,7 @@ local function onDeviceInfo(data) [101] = -112, [102] = -112, } - elseif vMaj == 3 then + elseif effMaj == 3 then info.RFMOD = { "", "25Hz", @@ -263,19 +262,36 @@ local function onDeviceInfo(data) 0, -101, } + else + info.RFMOD = nil + info.RFRSSI = nil end end +-- DEVICE_INFO handler: caches module name/identity and selects the RF tables +local function onDeviceInfo(data) + local info = crsf:decodeDeviceInfo(data) + if info == nil or info.id ~= crsf.CONST.ADDRESS_TX then + return -- short frame (the ping retries) or not the TX module + end + ElrsInfo.deviceInfo.name = info.name + ElrsInfo.deviceInfo.isElrs = info.isElrs + selectRfTables(info.vMaj) +end + -- ELRS_STATUS handler: latches the answer and updates modelMismatch local function onElrsStatus(data) - if data[2] ~= crsf.CONST.ADDRESS_TX then + local status = crsf:decodeElrsStatus(data) + if status == nil or status.id ~= crsf.CONST.ADDRESS_TX then return end - - TxInfo._statusAnswered = true - TxInfo.modelMismatch = bit32.btest(data[6] or 0, 4) or nil + ElrsInfo._statusAnswered = true + ElrsInfo.modelMismatch = status.modelMismatch end +-- Sole byte-level consumer of these frame types in this Lua state: the +-- decoders consume string bytes in place, so a handler registered behind +-- these would see decoded chars, not bytes. crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) @@ -283,4 +299,4 @@ crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) -- Return singleton -- ============================================================================ -return TxInfo +return ElrsInfo diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 87b91ce..b8d206d 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -3,12 +3,12 @@ -- Loaded via loadScript() from ELRSTelemetry/main.lua -- -- -- -- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- --- singleton for transport and txinfo.lua for device info/model match. -- +-- singleton for transport and elrsinfo.lua for device info/model match.-- -- -- -- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- --------------------------------------------------------------------------- -local zone, options, crsf, txinfo = ... +local zone, options, crsf, elrsinfo = ... -- Forward declarations for modules local Telemetry @@ -91,7 +91,7 @@ end --- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means --- anything while connected: ungated, a stale flag paints a label RED under text reading "--". function Telemetry.isMismatch() - return crsf.hasTelemetry and txinfo.modelMismatch + return crsf.hasTelemetry and elrsinfo.modelMismatch end --- Short status text when not operational or warning active. @@ -104,7 +104,7 @@ function Telemetry.statusText() if not crsf.hasTelemetry then return "No telemetry" end - if txinfo.modelMismatch then + if elrsinfo.modelMismatch then return "Model Mismatch" end return nil @@ -112,7 +112,7 @@ end --- Compute smoothed range percentage from RSSI. function Telemetry.getRangePct(tlm) - local mod = txinfo.deviceInfo + local mod = elrsinfo.deviceInfo local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 if rssi == nil then return 0 @@ -137,7 +137,7 @@ function Telemetry.getRfModeStr(rfmd) if not crsf.hasTelemetry or rfmd == nil then return "" end - local mod = txinfo.deviceInfo + local mod = elrsinfo.deviceInfo return (mod.RFMOD and mod.RFMOD[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) end @@ -458,7 +458,7 @@ local function buildFullScreen() if not crsf.hasTelemetry then return "No telemetry" end - if txinfo.modelMismatch then + if elrsinfo.modelMismatch then return "Model Mismatch" end return "Telemetry" @@ -670,7 +670,7 @@ local wgt = { function wgt.background() crsf:poll() - txinfo:update() + elrsinfo:update() Telemetry.update() end diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index 9311af1..67e5e08 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -16,14 +16,14 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end - if not _elrsTxInfoSingleton then - local getTxInfo = loadScript("/WIDGETS/" .. name .. "/txinfo.lua") + if not _elrsInfoSingleton then + local getElrsInfo = loadScript("/SCRIPTS/ELRS/elrsinfo.lua") ---@diagnostic disable-next-line: need-check-nil - _elrsTxInfoSingleton = getTxInfo(_crsfSingleton) + _elrsInfoSingleton = getElrsInfo(_crsfSingleton) end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton, _elrsTxInfoSingleton) + return loadable(zone, options, _crsfSingleton, _elrsInfoSingleton) end local function refresh(widget, event, touchState) From 574931bc45395c21fddb40af1a18046a5343499b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 12:19:51 +0300 Subject: [PATCH 153/218] Document the ELRS library decoders and the elrsinfo module --- README.md | 1 + docs/development.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 65cda7e..3576c60 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ When done, your SD card should contain: SCRIPTS/ ELRS/ crsf.lua -- shared CRSF protocol library + elrsinfo.lua -- TX module info state (telemetry widget) shim.lua -- BW compatibility shim TOOLS/ ExpressLRS/ diff --git a/docs/development.md b/docs/development.md index 4dfcaa2..a6c7b2b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,7 +44,8 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | Module | Purpose | |--------|---------| -| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry | +| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | +| `SCRIPTS/ELRS/elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | ## CRSF Simulator From 12a403966a4c48f5f99b4792aa38fbcf64aa6e09 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 14:57:33 +0300 Subject: [PATCH 154/218] Teach the CRSF simulator firmware-faithful parameter chunking encodeParameterEntry now slices entry payloads at maxPacketBytes - 8 exactly as CRSFEndpoint::sendParameter does (6 bytes CRSF header/CRC plus the FieldId/ChunksRemain pair repeated per frame), honouring the requested chunk index it previously ignored. config.maxPacketBytes defaults to CRSF_MAX_PACKET_LEN (64); lowering it emulates a slow-baud handset (CRSFHandset::adjustMaxPacketSize) for deeper chunking. Also adds the coverage the mock was missing: - an INT8 "RF Gain" field (value -3, min -10, max 10) on the TX device, plus INT8 two's-complement decode on write, so sign extension is exercised end to end - a "critical_error" scenario (connected + baud-rate error, flags 0x41) whose critical bits clear on the suppress-critical-errors write (pseudo-field FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E, the bare literal TXModuleEndpoint.cpp matches on) Verified on TX16S under scenario "normal": a full TX+RX parameter walk through the shipping tool protocol decodes identically to the layout, with Pitmode (3 chunks) and Packet Rate (2 chunks) reassembled complete and RF Gain read back as -3/-10/10. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 101 +++++++++++++++++++- 1 file changed, 96 insertions(+), 5 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 12e1407..a8b2269 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -45,8 +45,18 @@ local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -- in minimized widgets, full-screen subtitle updates). -- "no_module" No CRSF module found at all. Triggers the "No Module -- Found" error dialog immediately. +-- "critical_error" TX + RX connected but the module reports a critical +-- error (baud rate too low). Exercises the warning screen +-- and the suppress-critical-errors write (field id 0x2E), +-- which clears the flags until the script restarts. local config = { scenario = "normal", + -- Largest frame the handset link carries (CRSF_MAX_PACKET_LEN on a fast + -- link). PARAMETER_SETTINGS_ENTRY payloads larger than maxPacketBytes - 8 + -- are chunked exactly as CRSFEndpoint::sendParameter does. Real firmware + -- shrinks this on slow baud rates (CRSFHandset::adjustMaxPacketSize, floor + -- 15) -- lower it here to emulate a slow link and force deeper chunking. + maxPacketBytes = 64, } -- ============================================================================ @@ -92,6 +102,10 @@ local CRSF = { CMD_CONFIRMED = 4, CMD_CANCEL = 5, CMD_QUERY = 6, + + -- Pseudo-field id: a PARAMETER_WRITE to this id calls supressCriticalErrors() + -- in TXModuleEndpoint.cpp (the firmware uses the bare 0x2E literal). + FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E, } -- ============================================================================ @@ -238,13 +252,17 @@ local function encodeDeviceInfo(device, destAddr) end --- Encode a PARAMETER_SETTINGS_ENTRY packet (frame type 0x2B) --- Encodes one chunk of a parameter. Currently only single-chunk (chunk 0) supported. +-- Encodes one chunk of a parameter, chunking at the handset link's frame +-- limit exactly as CRSFEndpoint::sendParameter does: payloads larger than +-- config.maxPacketBytes - 8 are sliced, each frame repeating the +-- [dest, src, fieldId, chunksRemain] header with the countdown in +-- chunksRemain. -- @param device the device table (for id) -- @param param the parameter definition table --- @param chunk chunk index (0 for single-chunk params) +-- @param chunk requested chunk index (0-based) -- @param destAddr destination address -- @return data table suitable for queuePush(FRAMETYPE_PARAMETER_SETTINGS_ENTRY, data) -local function encodeParameterEntry(device, param, _chunk, destAddr) +local function encodeParameterEntry(device, param, chunk, destAddr) local data = {} data[1] = destAddr or CRSF.ADDRESS_HANDSET data[2] = device.id @@ -352,7 +370,27 @@ local function encodeParameterEntry(device, param, _chunk, destAddr) appendString(data, param.units or "") end - return data + -- Chunking per CRSFEndpoint::sendParameter: the payload from the parent + -- byte onward is sliced into (maxPacketBytes - 8)-byte chunks -- 6 bytes of + -- CRSF header/CRC plus the FieldId + ChunksRemain pair repeated per frame. + local chunkMax = config.maxPacketBytes - 8 + local body = {} + for i = 5, #data do + body[#body + 1] = data[i] + end + if #body <= chunkMax then + return data + end + local totalChunks = math.ceil(#body / chunkMax) + local k = chunk or 0 + if k >= totalChunks then + k = totalChunks - 1 + end + local out = { data[1], data[2], data[3], totalChunks - 1 - k } + for i = k * chunkMax + 1, math.min((k + 1) * chunkMax, #body) do + out[#out + 1] = body[i] + end + return out end --- Encode an ELRS_STATUS packet (frame type 0x2E) @@ -398,7 +436,7 @@ local txDevice = { serialNo = CRSF.ELRS_SERIAL_ID, hwVer = 0, swVer = 0x00030500, -- 3.5.0 - fieldCount = 24, -- total parameter count + fieldCount = 25, -- total parameter count params = { { id = 1, @@ -589,6 +627,20 @@ local txDevice = { -- Version + regulatory domain (name = version+domain, value = commit hash) { id = 23, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, + + -- Signed integer field (INT8): exercises sign extension on load and the + -- two's-complement re-encode on save. Not a real TX parameter. + { + id = 25, + parent = 0, + type = CRSF.INT8, + name = "RF Gain", + value = -3, + min = -10, + max = 10, + default = 0, + units = "dB", + }, }, } @@ -1012,6 +1064,11 @@ end -- bit 4: LUA_FLAG_WARNING1 -- bit 5: LUA_FLAG_ERROR_CONNECTED (critical) -- bit 6: LUA_FLAG_ERROR_BAUDRATE (critical) + +-- Set by a PARAMETER_WRITE to pseudo-field 0x2E (TXModuleEndpoint.cpp +-- supressCriticalErrors): critical flag bits stay cleared afterwards. +local criticalErrorsSuppressed = false + local function getElrsFlags() if config.scenario == "reconnect" then return isRxAvailable() and 0x01 or 0x00 @@ -1019,6 +1076,11 @@ local function getElrsFlags() return 0x05 -- connected + model mismatch elseif config.scenario == "armed" then return 0x09 -- connected + armed + elseif config.scenario == "critical_error" then + if criticalErrorsSuppressed then + return 0x01 -- connected, critical bits suppressed + end + return 0x41 -- connected + baud rate error (critical) elseif config.scenario == "normal" or config.scenario == "slow_loading" @@ -1036,6 +1098,8 @@ local function getElrsFlagsInfo() return "Model Mismatch" elseif config.scenario == "armed" then return "[ ! Armed ! ]" + elseif config.scenario == "critical_error" and not criticalErrorsSuppressed then + return "Baud rate too low" end return "" end @@ -1208,6 +1272,12 @@ local function mockPush(command, data) return true end + -- Special case: suppress-critical-errors write (TXModuleEndpoint.cpp). + if fieldId == CRSF.FIELD_ID_SUPPRESS_CRITICAL_ERRORS then + criticalErrorsSuppressed = true + return true + end + local device = findDeviceByAddr(deviceId) if device then local param = findParam(device, fieldId) @@ -1252,6 +1322,12 @@ local function mockPush(command, data) v = v - 0x10000 end param.value = v + elseif t == CRSF.INT8 then + local v = writeValue or 0 + if v >= 0x80 then + v = v - 0x100 + end + param.value = v else param.value = writeValue end @@ -1471,6 +1547,21 @@ local scenarioTelemetry = { GSpd = 25.3, Alt = 142, }, + critical_error = { + -- Same link as normal; only the ELRS status flags differ. + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + }, } -- Jitter ranges for sensors that fluctuate in real life. From 59345f27e86857ad10a07784beb05560ff338843 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:06:31 +0300 Subject: [PATCH 155/218] Extract the CRSF field codecs into the ELRS library src/SCRIPTS/ELRS/crsf_fields.lua now owns the stateless parameter-field codecs: the byte getters (readValue, readStringOrOpts) and decodeEntry, which masks the type byte, reads the hidden bit and name, and dispatches the per-type loaders (int with sign/width arithmetic, float, text selection with the dirty/identity cache, string, folder, command). Like elrsinfo.lua it is opt-in: telemetry-only widgets never load it. Unlike crsf.lua's fieldGetString, nothing in it mutates the frame data table, so it is safe under the poll() handler fan-out. protocol.lua keeps the policy that was interleaved with the decode: the load-queue pop, the fieldHiddenChanged notification, the CMD_IDLE popup-clear + related-fields reload (formerly inside fieldCommandLoad), and the root/background child auto-queue. Its chunk reassembly is unchanged. The tool no longer loads shim.lua -- its only uses moved with the codecs. Verified: full TX+RX field dumps through the tool protocol are byte-identical to the pre-move baseline on TX16S and on GX12 (whose Lua state has no table library, proving the shim discipline holds). --- src/SCRIPTS/ELRS/crsf_fields.lua | 239 ++++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 4 +- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 214 ++----------------- 3 files changed, 262 insertions(+), 195 deletions(-) create mode 100644 src/SCRIPTS/ELRS/crsf_fields.lua diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_fields.lua new file mode 100644 index 0000000..f6acb33 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_fields.lua @@ -0,0 +1,239 @@ +--------------------------------------------------------------------------- +-- CRSF Parameter-Field Codec -- +-- -- +-- Stateless codecs for CRSF parameter fields: byte getters and the -- +-- per-type PARAMETER_SETTINGS_ENTRY (0x2B) payload decoders. Opt-in: -- +-- only consumers that read or write parameter fields load it, so -- +-- telemetry-only widgets never pay for it. Policy -- load queues, -- +-- command popups, reload decisions -- stays with the caller. -- +-- -- +-- Purity rule: nothing in this file mutates a frame data table. -- +-- crsf:poll() hands the same table to every registered handler, so an -- +-- in-place decode here would corrupt the frame for sibling handlers -- +-- (the hazard crsf.lua's fieldGetString documents). -- +-- -- +-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_fields.lua")(crsf). -- +-- Returns the codec table directly. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + +local Fields = {} + +-- ============================================================================ +-- Byte getters +-- ============================================================================ + +--- Read a big-endian unsigned integer from a byte array. +-- @param data array of byte values +-- @param offset 1-based start offset +-- @param size width in bytes +-- @return number +function Fields.readValue(data, offset, size) + local result = 0 + for i = 0, size - 1 do + result = bit32.lshift(result, 8) + data[offset + i] + end + return result +end + +--- Read a null-terminated string, or a semicolon-separated option list, from +-- a byte array without mutating it. Translates the legacy ELRS arrow bytes +-- (0xC0/0xC1) to the EdgeTX CHAR_UP/CHAR_DOWN glyphs. +-- @param data array of byte values +-- @param offset 1-based start offset +-- @param last cached previous result: when given, decoding is skipped and +-- it is returned as-is (the offset still advances past the +-- terminator). Only safe while no reload has flagged the +-- content as possibly changed. +-- @param isOpts truthy to split on ';' and return a table of options +-- @return string|table result, number nextOffset, number optCount (count of +-- non-empty options; 0 when cached or not isOpts) +function Fields.readStringOrOpts(data, offset, last, isOpts) + local r = last or (isOpts and {}) + local optParts = {} + local vcnt = 0 + repeat + local b = data[offset] + offset = offset + 1 + + if not last then + if r and (b == 59 or b == 0) then + r[#r + 1] = shim.tableConcat(optParts) + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + elseif b ~= 0 then + -- Translate legacy arrow bytes (0xC0/0xC1) from ELRS firmware + -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs + if b == 192 and CHAR_UP then + optParts[#optParts + 1] = CHAR_UP + elseif b == 193 and CHAR_DOWN then + optParts[#optParts + 1] = CHAR_DOWN + else + optParts[#optParts + 1] = string.char(b) + end + end + end + until b == 0 + + return (r or shim.tableConcat(optParts)), offset, vcnt +end + +-- ============================================================================ +-- Per-type field loaders +-- ============================================================================ + +local function fieldUnsignedLoad(field, data, offset, size, unitoffset) + field.value = Fields.readValue(data, offset, size) + field.min = Fields.readValue(data, offset + size, size) + field.max = Fields.readValue(data, offset + 2 * size, size) + local unit = Fields.readStringOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + field.unit = (unit ~= "") and unit or nil + if size ~= 1 then + field.size = size + end +end + +local function fieldUnsignedToSigned(field, size) + local bandval = bit32.lshift(0x80, (size - 1) * 8) + field.value = field.value - bit32.band(field.value, bandval) * 2 + field.min = field.min - bit32.band(field.min, bandval) * 2 + field.max = field.max - bit32.band(field.max, bandval) * 2 +end + +local function fieldSignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedToSigned(field, size) + field.size = -size +end + +local function fieldIntLoad(field, data, offset) + local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad + return loadFn(field, data, offset, math.floor(field.type / 2) + 1) +end + +local function fieldFloatLoad(field, data, offset) + fieldSignedLoad(field, data, offset, 4, 21) + field.prec = data[offset + 16] + if field.prec > 3 then + field.prec = 3 + end + field.step = Fields.readValue(data, offset + 17, 4) + field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" }) + field.prec = 10 ^ field.prec +end + +local function fieldTextSelLoad(field, data, offset) + local vcnt + local oldValues = field.values + local cached = field.dirty == nil and oldValues + field.values, offset, vcnt = Fields.readStringOrOpts(data, offset, cached, true) + if not cached then + field.disabled = (vcnt <= 1) or nil + -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) + if oldValues and #oldValues == #field.values then + local same = true + for i = 1, #field.values do + if oldValues[i] ~= field.values[i] then + same = false + break + end + end + if same then + field.values = oldValues + end + end + end + field.value = data[offset] + local unit = Fields.readStringOrOpts(data, offset + 4) + field.unit = (unit ~= "") and unit or nil + field.dirty = nil +end + +local function fieldStringLoad(field, data, offset) + field.value, offset = Fields.readStringOrOpts(data, offset) + if #data >= offset then + field.maxlen = data[offset] + end +end + +local function fieldCommandLoad(field, data, offset) + field.status = data[offset] + field.timeout = data[offset + 1] + local info = Fields.readStringOrOpts(data, offset + 2) + field.info = (info ~= "") and info or nil +end + +local function fieldFolderLoad(field, data, offset) + field.children = {} + while data[offset] and data[offset] ~= crsf.CONST.FIELD_LIST_END do + field.children[#field.children + 1] = data[offset] + offset = offset + 1 + end +end + +-- Per-type load dispatch, keyed by wire type id + 1. +-- UINT32..INT64 are unsupported (nil slots), as in the ELRS firmware. +local handlers = { + [crsf.CONST.FIELD_UINT8 + 1] = fieldIntLoad, + [crsf.CONST.FIELD_INT8 + 1] = fieldIntLoad, + [crsf.CONST.FIELD_UINT16 + 1] = fieldIntLoad, + [crsf.CONST.FIELD_INT16 + 1] = fieldIntLoad, + [crsf.CONST.FIELD_FLOAT + 1] = fieldFloatLoad, + [crsf.CONST.FIELD_TEXT_SELECTION + 1] = fieldTextSelLoad, + [crsf.CONST.FIELD_STRING + 1] = fieldStringLoad, + [crsf.CONST.FIELD_FOLDER + 1] = fieldFolderLoad, + [crsf.CONST.FIELD_INFO + 1] = fieldStringLoad, + [crsf.CONST.FIELD_COMMAND + 1] = fieldCommandLoad, +} + +-- ============================================================================ +-- Entry decode +-- ============================================================================ + +--- Decode a complete PARAMETER_SETTINGS_ENTRY payload into the caller-owned +-- field table. Sets field.id, parent (0 -> nil), type (0x7F-masked), hidden +-- (0x80 bit, true/nil), name, and dispatches the per-type loader, which fills +-- value/min/max/unit (ints, floats), prec/step/fmt (floats), values/disabled +-- (text selections), maxlen (strings), status/timeout/info (commands) or +-- children (folders). min/max of 0 are normalized to nil. +-- @param field the caller-owned field table to decode into +-- @param fieldId the field id the payload belongs to +-- @param buffer byte array holding the payload +-- @param offset 1-based offset of the parent byte within buffer +-- @param cachedName pass the previous name to skip its decode (see +-- readStringOrOpts); nil decodes it fresh +-- @return field, or nil when the entry is shorter than parent + type + one +-- name byte (the caller should still drop it from its queue) +function Fields.decodeEntry(field, fieldId, buffer, offset, cachedName) + -- Need at least parent + type + one name byte for the entry to be usable + if #buffer <= offset + 2 then + return nil + end + field.id = fieldId + field.parent = (buffer[offset] ~= 0) and buffer[offset] or nil + field.type = bit32.band(buffer[offset + 1], 0x7f) + field.hidden = bit32.btest(buffer[offset + 1], 0x80) or nil + field.name, offset = Fields.readStringOrOpts(buffer, offset + 2, cachedName) + local load = handlers[field.type + 1] + if load then + load(field, buffer, offset) + end + if field.min == 0 then + field.min = nil + end + if field.max == 0 then + field.max = nil + end + return field +end + +-- ============================================================================ +-- Return codec table +-- ============================================================================ + +return Fields diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 208e415..d881719 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -15,9 +15,9 @@ local useLvgl = (lvgl ~= nil) -- Load shared modules -- ============================================================================ -local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(crsf, shim) +local fields = loadScript("/SCRIPTS/ELRS/crsf_fields.lua")(crsf) +local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(crsf, fields) local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 143bbb2..029405a 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -3,7 +3,7 @@ ---- # Shared between BW and LVGL UI implementations # ---- ######################################################################### -local crsf, shim = ... +local crsf, fields = ... local Protocol = { -- Tool-internal pseudo field types for the synthetic device rows in the @@ -224,154 +224,6 @@ function Protocol.startBackgroundLoad() end end --- ============================================================================ --- Field data helpers --- ============================================================================ - -function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) - local r = last or (isOpts and {}) - local optParts = {} - local vcnt = 0 - repeat - local b = data[offset] - offset = offset + 1 - - if not last then - if r and (b == 59 or b == 0) then - r[#r + 1] = shim.tableConcat(optParts) - if #optParts > 0 then - vcnt = vcnt + 1 - optParts = {} - end - elseif b ~= 0 then - -- Translate legacy arrow bytes (0xC0/0xC1) from ELRS firmware - -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs - if b == 192 and CHAR_UP then - optParts[#optParts + 1] = CHAR_UP - elseif b == 193 and CHAR_DOWN then - optParts[#optParts + 1] = CHAR_DOWN - else - optParts[#optParts + 1] = string.char(b) - end - end - end - until b == 0 - - return (r or shim.tableConcat(optParts)), offset, vcnt -end - -function Protocol.fieldGetValue(data, offset, size) - local result = 0 - for i = 0, size - 1 do - result = bit32.lshift(result, 8) + data[offset + i] - end - return result -end - --- ============================================================================ --- Field load functions --- ============================================================================ - -local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = Protocol.fieldGetValue(data, offset, size) - field.min = Protocol.fieldGetValue(data, offset + size, size) - field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) - local unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) - field.unit = (unit ~= "") and unit or nil - if size ~= 1 then - field.size = size - end -end - -local function fieldUnsignedToSigned(field, size) - local bandval = bit32.lshift(0x80, (size - 1) * 8) - field.value = field.value - bit32.band(field.value, bandval) * 2 - field.min = field.min - bit32.band(field.min, bandval) * 2 - field.max = field.max - bit32.band(field.max, bandval) * 2 -end - -local function fieldSignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedToSigned(field, size) - field.size = -size -end - -function Protocol.fieldIntLoad(field, data, offset) - local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad - return loadFn(field, data, offset, math.floor(field.type / 2) + 1) -end - -function Protocol.fieldFloatLoad(field, data, offset) - fieldSignedLoad(field, data, offset, 4, 21) - field.prec = data[offset + 16] - if field.prec > 3 then - field.prec = 3 - end - field.step = Protocol.fieldGetValue(data, offset + 17, 4) - field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" }) - field.prec = 10 ^ field.prec -end - -function Protocol.fieldTextSelLoad(field, data, offset) - local vcnt - local oldValues = field.values - local cached = field.dirty == nil and oldValues - field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) - if not cached then - field.disabled = (vcnt <= 1) or nil - -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) - if oldValues and #oldValues == #field.values then - local same = true - for i = 1, #field.values do - if oldValues[i] ~= field.values[i] then - same = false - break - end - end - if same then - field.values = oldValues - end - end - end - field.value = data[offset] - local unit = Protocol.fieldGetStrOrOpts(data, offset + 4) - field.unit = (unit ~= "") and unit or nil - field.dirty = nil -end - -function Protocol.fieldStringLoad(field, data, offset) - field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) - if #data >= offset then - field.maxlen = data[offset] - end -end - -function Protocol.fieldCommandLoad(field, data, offset) - field.status = data[offset] - field.timeout = data[offset + 1] - local info = Protocol.fieldGetStrOrOpts(data, offset + 2) - field.info = (info ~= "") and info or nil - if field.status == crsf.CONST.CMD_IDLE then - -- A command that was actively running just finished (or was cancelled): - -- re-read its same-level fields so the current page reflects any values the - -- command changed. The guard limits this to the active command -- routine - -- loads of idle command fields while browsing (fieldPopup is nil) must not - -- trigger a reload. - if Protocol.fieldPopup == field then - Protocol.reloadRelatedFields(field) - end - Protocol.fieldPopup = nil - end -end - -function Protocol.fieldFolderLoad(field, data, offset) - field.children = {} - while data[offset] and data[offset] ~= crsf.CONST.FIELD_LIST_END do - field.children[#field.children + 1] = data[offset] - offset = offset + 1 - end -end - -- ============================================================================ -- Field save functions -- ============================================================================ @@ -484,27 +336,6 @@ function Protocol.commandCancel() end end --- ============================================================================ --- Handlers dispatch table --- ============================================================================ - -Protocol.handlers = { - [crsf.CONST.FIELD_UINT8 + 1] = Protocol.fieldIntLoad, - [crsf.CONST.FIELD_INT8 + 1] = Protocol.fieldIntLoad, - [crsf.CONST.FIELD_UINT16 + 1] = Protocol.fieldIntLoad, - [crsf.CONST.FIELD_INT16 + 1] = Protocol.fieldIntLoad, - [crsf.CONST.FIELD_UINT32 + 1] = nil, - [crsf.CONST.FIELD_INT32 + 1] = nil, - [crsf.CONST.FIELD_UINT64 + 1] = nil, - [crsf.CONST.FIELD_INT64 + 1] = nil, - [crsf.CONST.FIELD_FLOAT + 1] = Protocol.fieldFloatLoad, - [crsf.CONST.FIELD_TEXT_SELECTION + 1] = Protocol.fieldTextSelLoad, - [crsf.CONST.FIELD_STRING + 1] = Protocol.fieldStringLoad, - [crsf.CONST.FIELD_FOLDER + 1] = Protocol.fieldFolderLoad, - [crsf.CONST.FIELD_INFO + 1] = Protocol.fieldStringLoad, - [crsf.CONST.FIELD_COMMAND + 1] = Protocol.fieldCommandLoad, -} - -- ============================================================================ -- CRSF message parsing -- ============================================================================ @@ -564,32 +395,29 @@ function Protocol.parseParameterInfoMessage(data) -- Field data stream is now complete, process into a field Protocol.loadQueue[#Protocol.loadQueue] = nil - -- Need at least parent + type + one name byte for the entry to be usable - if #Protocol.fieldData > (offset + 2) then - field.id = fieldId - field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil - field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) - -- Hidden bit flipped, so the UI's cached list of visible fields has to be rebuilt - local wasHidden = field.hidden - field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil - if field.hidden ~= wasHidden then - Protocol.fieldHiddenChanged = true - end - -- Passing the old name makes fieldGetStrOrOpts skip the decode and reuse that string, - -- which is only safe while no reload has flagged the name as possibly changed - local cachedName = (not field.nameStale and not field.reloading) and field.name or nil - field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) + -- Hidden-bit changes rebuild the UI's cached list of visible fields, so + -- track it across the decode. + local wasHidden = field.hidden + -- Passing the old name makes the decoder skip its read and reuse that string, + -- which is only safe while no reload has flagged the name as possibly changed + local cachedName = (not field.nameStale and not field.reloading) and field.name or nil + if fields.decodeEntry(field, fieldId, Protocol.fieldData, offset, cachedName) then field.nameStale = nil field.reloading = nil - local load = Protocol.handlers[field.type + 1] - if load then - load(field, Protocol.fieldData, offset) - end - if field.min == 0 then - field.min = nil + if field.hidden ~= wasHidden then + Protocol.fieldHiddenChanged = true end - if field.max == 0 then - field.max = nil + + if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE then + -- A command that was actively running just finished (or was cancelled): + -- re-read its same-level fields so the current page reflects any values + -- the command changed. The guard limits this to the active command -- + -- routine loads of idle command fields while browsing (fieldPopup is + -- nil) must not trigger a reload. + if Protocol.fieldPopup == field then + Protocol.reloadRelatedFields(field) + end + Protocol.fieldPopup = nil end -- Auto-queue children for root folder (field 0) and during background preloading. From f04caf2a342584afc39be4f86dcf248f86f8369f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:19:37 +0300 Subject: [PATCH 156/218] Move the field save and command writers into the ELRS library crsf_fields.lua gains the session-addressed frame senders: sendWriteInt (two's-complement re-encode at the field's width), sendWriteString (maxlen clamp, NUL-strip, terminator), sendCommandStep (the one-byte commandStep_e write behind CLICK/CONFIRMED/CANCEL/QUERY) and sendSuppressCriticalErrors (pseudo-field 0x2E, the bare literal TXModuleEndpoint.cpp matches on). Each reads deviceId/handsetId from a caller-owned session table -- the tool passes its Protocol table. The UIs receive the codec via deps and call the two pure save operations directly; everything with policy stays behind protocol.lua: handleCommandSave, commandConfirm, commandCancel (send + dismiss), the new commandRequestCancel (send + keep the popup for the device's CMD_IDLE echo) and suppressCriticalErrors (optimistic local flag clear + the suppress write). lcd.lua no longer builds frames: the warning screen calls suppressCriticalErrors(), and drawPopup's EXIT handling is routed per status -- ASKCONFIRM/EXECUTING through commandCancel() exactly like the colour UI, everything else through commandRequestCancel(). The one deliberate wire change: EXIT during an executing command sends a single CMD_CANCEL where the old code pushed it twice (once at the top of drawPopup, again via popupConfirmation's CANCEL result). Verified on TX16S: RF Gain -3 -> -4 edit round-trips (negative encode, mock decode, sign-extended read-back), a Packet Rate change reloads siblings (Telem Ratio units recompute), Bind runs CLICK -> 4 QUERYs -> natural completion with the popup dismissed by the IDLE echo, and Enable WiFi runs ASKCONFIRM -> CONFIRMED -> EXECUTING -> one CANCEL. On GX12: the critical-error banner clears with the 0x2E write on the wire, and EXIT during Bind's executing popup sends CLICK + 2 QUERYs + exactly one CANCEL with no error. --- src/SCRIPTS/ELRS/crsf_fields.lua | 75 +++++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 1 + src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 74 +++++++--------------- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 18 +++--- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 9 +-- 5 files changed, 114 insertions(+), 63 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_fields.lua index f6acb33..0f23e0b 100644 --- a/src/SCRIPTS/ELRS/crsf_fields.lua +++ b/src/SCRIPTS/ELRS/crsf_fields.lua @@ -232,6 +232,81 @@ function Fields.decodeEntry(field, fieldId, buffer, offset, cachedName) return field end +-- ============================================================================ +-- Frame senders +-- +-- Every sender takes a caller-owned session table and reads its addressing +-- bytes: session.deviceId (the target device) and session.handsetId (the +-- reply-to address). Wire layouts match the tables in CRSFParameters.h. +-- ============================================================================ + +--- Send a PARAMETER_WRITE carrying a field's integer value, big-endian at the +-- field's width. field.size < 0 marks a signed field |size| bytes wide +-- (decodeEntry's convention); negative values are re-encoded as two's +-- complement. A missing size means 1 byte. +-- @param session table with deviceId/handsetId +-- @param field table with id, value and optional size +function Fields.sendWriteInt(session, field) + local value = field.value + local size = field.size or 1 + if size < 0 then + size = -size + if value < 0 then + value = bit32.lshift(0x100, (size - 1) * 8) + value + end + end + + local frame = { session.deviceId, session.handsetId, field.id } + for i = size - 1, 0, -1 do + frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 + end + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) +end + +--- Send a PARAMETER_WRITE carrying a field's string value, clamped to +-- field.maxlen (default 32), inner NULs stripped, null-terminated. +-- @param session table with deviceId/handsetId +-- @param field table with id, value and optional maxlen +function Fields.sendWriteString(session, field) + local frame = { session.deviceId, session.handsetId, field.id } + local val = field.value or "" + local maxlen = field.maxlen or 32 + if #val > maxlen then + val = string.sub(val, 1, maxlen) + end + for i = 1, #val do + local b = string.byte(val, i) + if b ~= 0 then + frame[#frame + 1] = b + end + end + frame[#frame + 1] = 0 + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) +end + +--- Send a command-step PARAMETER_WRITE: one byte from the commandStep_e +-- machine (crsf.CONST.CMD_CLICK / CMD_CONFIRMED / CMD_CANCEL / CMD_QUERY). +-- @param session table with deviceId/handsetId +-- @param fieldId the command field's id +-- @param step the command step byte +function Fields.sendCommandStep(session, fieldId, step) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { session.deviceId, session.handsetId, fieldId, step }) +end + +-- Pseudo-field id: a PARAMETER_WRITE to this id calls supressCriticalErrors() +-- in TXModuleEndpoint.cpp (the firmware matches the bare 0x2E literal). +local FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E + +--- Ask the module to stop reporting its critical error flags (the bits above +-- crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD in the ELRS status byte). +-- @param session table with deviceId/handsetId +function Fields.sendSuppressCriticalErrors(session) + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { session.deviceId, session.handsetId, FIELD_ID_SUPPRESS_CRITICAL_ERRORS, 0 } + ) +end + -- ============================================================================ -- Return codec table -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index d881719..34bfa14 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -102,6 +102,7 @@ local function init() Navigation = Navigation, Protocol = Protocol, crsf = crsf, + fields = fields, VERSION = VERSION, } if useLvgl then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 029405a..017fd55 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -224,44 +224,6 @@ function Protocol.startBackgroundLoad() end end --- ============================================================================ --- Field save functions --- ============================================================================ - -function Protocol.fieldIntSave(field) - local value = field.value - local size = field.size or 1 - if size < 0 then - size = -size - if value < 0 then - value = bit32.lshift(0x100, (size - 1) * 8) + value - end - end - - local frame = { Protocol.deviceId, Protocol.handsetId, field.id } - for i = size - 1, 0, -1 do - frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 - end - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) -end - -function Protocol.fieldStringSave(field) - local frame = { Protocol.deviceId, Protocol.handsetId, field.id } - local val = field.value or "" - local maxlen = field.maxlen or 32 - if #val > maxlen then - val = string.sub(val, 1, maxlen) - end - for i = 1, #val do - local b = string.byte(val, i) - if b ~= 0 then - frame[#frame + 1] = b - end - end - frame[#frame + 1] = 0 - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) -end - -- ============================================================================ -- Related fields reload (for value changes) -- ============================================================================ @@ -307,7 +269,7 @@ function Protocol.handleCommandSave(field) if field.status ~= nil then if field.status < crsf.CONST.CMD_CONFIRMED then field.status = crsf.CONST.CMD_CLICK - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, field.id, field.status }) + fields.sendCommandStep(Protocol, field.id, crsf.CONST.CMD_CLICK) Protocol.fieldPopup = field Protocol.fieldPopup.lastStatus = crsf.CONST.CMD_IDLE Protocol.fieldTimeout = getTime() + field.timeout @@ -317,25 +279,38 @@ end function Protocol.commandConfirm() if Protocol.fieldPopup then - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED } - ) + fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED) Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout Protocol.fieldPopup.status = crsf.CONST.CMD_CONFIRMED end end +-- Cancel and dismiss: sends CMD_CANCEL and drops the popup immediately. function Protocol.commandCancel() if Protocol.fieldPopup then - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL } - ) + fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) Protocol.fieldPopup = nil end end +-- Cancel but keep the popup: sends CMD_CANCEL and waits for the device to +-- report CMD_IDLE, which dismisses the popup through the entry decode. Used +-- while no dialog is on screen yet (e.g. right after CMD_CLICK), so the UI +-- keeps tracking the device's actual command state. +function Protocol.commandRequestCancel() + if Protocol.fieldPopup then + fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) + Protocol.fieldTimeout = getTime() + 200 + end +end + +-- Clear the ELRS critical-error banner: optimistic local clear plus the +-- suppress write the module acts on. +function Protocol.suppressCriticalErrors() + Protocol.elrsFlags = 0 + fields.sendSuppressCriticalErrors(Protocol) +end + -- ============================================================================ -- CRSF message parsing -- ============================================================================ @@ -510,10 +485,7 @@ function Protocol.tick() if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= crsf.CONST.CMD_ASKCONFIRM then - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY } - ) + fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end elseif time > Protocol.linkstatTimeout then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 79273d7..70527c2 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -9,6 +9,7 @@ local App = deps.App local Navigation = deps.Navigation local Protocol = deps.Protocol local crsf = deps.crsf +local fields = deps.fields local VERSION = deps.VERSION local versionCheckResult = nil @@ -498,8 +499,7 @@ function UI.handleEvent(event) end elseif event == EVT_VIRTUAL_ENTER then if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then - Protocol.elrsFlags = 0 - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + Protocol.suppressCriticalErrors() elseif UI.isOnBackExit() then if Navigation.isAtRoot() then App.shouldExit = true @@ -591,11 +591,13 @@ end function UI.drawPopup(event) if event == EVT_VIRTUAL_EXIT then - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL } - ) - Protocol.fieldTimeout = getTime() + 200 + local status = Protocol.fieldPopup.status + if status ~= crsf.CONST.CMD_ASKCONFIRM and status ~= crsf.CONST.CMD_EXECUTING then + -- No dialog is on screen yet (e.g. CMD_CLICK just went out): request the + -- cancel but keep the popup up until the device reports CMD_IDLE. The + -- dialog branches below handle their own cancel via popupConfirmation. + Protocol.commandRequestCancel() + end end if Protocol.fieldPopup.status == crsf.CONST.CMD_ASKCONFIRM then @@ -604,7 +606,7 @@ function UI.drawPopup(event) if result == "OK" then Protocol.commandConfirm() elseif result == "CANCEL" then - Protocol.fieldPopup = nil + Protocol.commandCancel() end elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then if Protocol.fieldChunk == 0 then diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 61395fd..0a9cf0e 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -9,6 +9,7 @@ local App = deps.App local Navigation = deps.Navigation local Protocol = deps.Protocol local crsf = deps.crsf +local fields = deps.fields local VERSION = deps.VERSION -- ============================================================================ @@ -700,7 +701,7 @@ function UI.createToggleRow(pg, field) end, set = function(val) field.value = val - Protocol.fieldIntSave(field) + fields.sendWriteInt(Protocol, field) Protocol.reloadRelatedFields(field) end, active = function() @@ -765,7 +766,7 @@ function UI.createChoiceRow(pg, field) end, set = function(val) field.value = val - 1 - Protocol.fieldIntSave(field) + fields.sendWriteInt(Protocol, field) Protocol.reloadRelatedFields(field) end, active = function() @@ -804,7 +805,7 @@ function UI.createNumberRow(pg, field) end, edited = function(val) field.value = val - Protocol.fieldIntSave(field) + fields.sendWriteInt(Protocol, field) Protocol.reloadRelatedFields(field) end, display = function(val) @@ -900,7 +901,7 @@ function UI.createStringRow(pg, field) length = math.min(math.max(field.maxlen or 32, 32), 128), set = function(val) field.value = val - Protocol.fieldStringSave(field) + fields.sendWriteString(Protocol, field) Protocol.reloadRelatedFields(field) end, active = function() From c7331c9abc8cdc563f85e6c8d9c8ec7033e1b744 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:25:21 +0300 Subject: [PATCH 157/218] Move chunk reassembly and the read request into the ELRS library crsf_fields.lua gains reassemble()/resetChunks(), a line-for-line port of the tool's PARAMETER_SETTINGS_ENTRY chunk handling operating on session-table state (fieldChunk, fieldData, expectChunksRemain), and sendRead(), which carries session.fieldChunk so follow-up reads of a chunked entry continue where reassembly left off. Two deliberate deltas from the old inline code, both inert for the tool: reassemble() takes an explicit expectedFieldId so a passive consumer under the crsf:poll() fan-out can pass data[3] and accept any field from its device, and a new fieldDataId gate keeps a sibling-elicited entry for another field out of an in-flight buffer -- required for that fan-out mode, unreachable when the expected id is pinned. The unallocated-field guard moved from before-buffering to the caller's decode gate (unreachable either way: the expected id always comes from loadQueue/fieldPopup, both within the allocated range). protocol.lua keeps the policy wrapper: expected-id selection from popup/queue, queue pop on completion, decode via decodeEntry, and the reload/auto-queue rules. Its manual chunk-state clears became resetChunks(). Verified: field dumps byte-identical to the pre-refactor baseline on TX16S (maxPacketBytes 64 and 22 -- 14-byte chunks splitting names mid-string) and GX12; slow_loading shows the loading gauge progressing through the 0.5 s retry cadence against 2 s responses with no errors. --- src/SCRIPTS/ELRS/crsf_fields.lua | 92 +++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 117 +++++++++------------- 2 files changed, 138 insertions(+), 71 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_fields.lua index 0f23e0b..a6f7019 100644 --- a/src/SCRIPTS/ELRS/crsf_fields.lua +++ b/src/SCRIPTS/ELRS/crsf_fields.lua @@ -191,6 +191,89 @@ local handlers = { [crsf.CONST.FIELD_COMMAND + 1] = fieldCommandLoad, } +-- ============================================================================ +-- Chunk reassembly +-- +-- PARAMETER_SETTINGS_ENTRY payloads larger than the handset link's frame +-- limit arrive in chunks (CRSFEndpoint::sendParameter). Reassembly state +-- lives in a caller-owned session table; the library manages these keys: +-- fieldChunk next chunk index to request (readable; do not write) +-- fieldData reassembly buffer for the in-flight entry +-- fieldDataId the field id the buffer belongs to +-- expectChunksRemain duplicate-frame guard +-- Consumers initialize fieldChunk = 0 and expectChunksRemain = -1 alongside +-- their deviceId/handsetId addressing bytes. +-- ============================================================================ + +--- Abandon any in-flight reassembly. +-- @param session the session table +function Fields.resetChunks(session) + session.fieldChunk = 0 + session.fieldData = nil + session.fieldDataId = nil +end + +--- Feed one PARAMETER_SETTINGS_ENTRY frame into the session. +-- expectedFieldId selects the consumption model: a caller waiting on one +-- specific field passes its id (nil while idle drops everything), a caller +-- listening passively under the poll() fan-out passes data[3] to accept any +-- field from session.deviceId -- the fieldDataId gate then keeps a +-- sibling-elicited entry for another field out of an in-flight buffer. +-- Never mutates data, and never retains it: the single-frame fast path +-- returns data itself as the buffer, valid only for the current call. +-- @param session the session table +-- @param data the frame's byte array +-- @param expectedFieldId the field id to accept +-- @return fieldId, buffer, offset entry complete; buffer[offset] is the +-- parent byte, ready for decodeEntry +-- @return fieldId chunk consumed, more expected -- send the +-- next read, which carries the updated session.fieldChunk +-- @return nil frame dropped (wrong device or field, +-- cross-field continuation, duplicate chunk) +function Fields.reassemble(session, data, expectedFieldId) + -- Another device answered, or this is not the awaited field: drop any + -- partial data + if data[2] ~= session.deviceId or data[3] ~= expectedFieldId then + Fields.resetChunks(session) + return nil + end + -- An in-flight buffer only accepts continuation frames for its own field + if session.fieldData and session.fieldDataId ~= data[3] then + return nil + end + local chunksRemain = data[4] + -- chunksRemain changed while data is buffered: duplicate frame, drop it + if session.fieldData and chunksRemain ~= session.expectChunksRemain then + return nil + end + + local buffer + local offset + -- If data is chunked, copy it to the persistent buffer + if chunksRemain > 0 or session.fieldChunk > 0 then + session.fieldData = session.fieldData or {} + session.fieldDataId = data[3] + buffer = session.fieldData + for i = 5, #data do + buffer[#buffer + 1] = data[i] + end + offset = 1 + else + -- All data arrived in one chunk, hand the frame back directly + buffer = data + offset = 5 + end + + if chunksRemain > 0 then + session.fieldChunk = session.fieldChunk + 1 + session.expectChunksRemain = chunksRemain - 1 + return data[3] + end + + Fields.resetChunks(session) + return data[3], buffer, offset +end + -- ============================================================================ -- Entry decode -- ============================================================================ @@ -240,6 +323,15 @@ end -- reply-to address). Wire layouts match the tables in CRSFParameters.h. -- ============================================================================ +--- Request one chunk of a field's PARAMETER_SETTINGS_ENTRY. The chunk index +-- rides in session.fieldChunk, so follow-up reads of a chunked entry continue +-- where reassemble() left off (0 requests a fresh entry). +-- @param session table with deviceId/handsetId and fieldChunk +-- @param fieldId the field id to read +function Fields.sendRead(session, fieldId) + crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, { session.deviceId, session.handsetId, fieldId, session.fieldChunk }) +end + --- Send a PARAMETER_WRITE carrying a field's integer value, big-endian at the -- field's width. field.size < 0 marks a signed field |size| bytes wide -- (decodeEntry's convention); negative values are re-encoded as two's diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 017fd55..2feb3bc 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -42,10 +42,13 @@ local Protocol = { linkstatTimeout = 100, pingTimeout = 0, - -- Communication state + -- Communication state. fieldChunk/fieldData/fieldDataId/expectChunksRemain + -- are the crsf_fields.lua reassembly session keys -- the library manages + -- them; fieldChunk stays readable (the BW UI's popup spinner ticks on it). fieldTimeout = 0, fieldChunk = 0, fieldData = nil, + fieldDataId = nil, loadQueue = {}, expectChunksRemain = -1, backgroundLoading = false, @@ -146,8 +149,7 @@ end function Protocol.reloadAllFields() Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil + fields.resetChunks(Protocol) Protocol.loadQueue = {} -- Start by loading only field 0 (root folder). -- Its response contains child IDs; only root children are auto-queued. @@ -181,8 +183,7 @@ end function Protocol.reloadCurField(field) Protocol.fieldTimeout = 0 - Protocol.fieldChunk = 0 - Protocol.fieldData = nil + fields.resetChunks(Protocol) Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id end @@ -332,79 +333,57 @@ function Protocol.parseDeviceInfoMessage(data) return device, isNew end --- Handle a parameter settings entry (0x2B) frame. Field data can span several frames, --- so this reassembles the chunks before decoding one field. +-- Handle a parameter settings entry (0x2B) frame. Field data can span several +-- frames; the library reassembles the chunks before this decodes one field. function Protocol.parseParameterInfoMessage(data) - local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] - -- Another device answered, or this is not the field we are waiting for: drop any partial data - if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 + local expectedId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + local fieldId, buffer, offset = fields.reassemble(Protocol, data, expectedId) + if not fieldId then return end local field = Protocol.fields[fieldId] - local chunksRemain = data[4] - -- If no field or the chunksremain changed when we have data, don't continue - if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then + if not field then + return + end + if not buffer then + -- Chunk consumed; the next read goes out with the updated chunk index return end - local offset - -- If data is chunked, copy it to persistent buffer - if chunksRemain > 0 or Protocol.fieldChunk > 0 then - Protocol.fieldData = Protocol.fieldData or {} - for i = 5, #data do - Protocol.fieldData[#Protocol.fieldData + 1] = data[i] + -- Field data stream is now complete, process into a field + Protocol.loadQueue[#Protocol.loadQueue] = nil + + -- Hidden-bit changes rebuild the UI's cached list of visible fields, so + -- track it across the decode. + local wasHidden = field.hidden + -- Passing the old name makes the decoder skip its read and reuse that string, + -- which is only safe while no reload has flagged the name as possibly changed + local cachedName = (not field.nameStale and not field.reloading) and field.name or nil + if fields.decodeEntry(field, fieldId, buffer, offset, cachedName) then + field.nameStale = nil + field.reloading = nil + if field.hidden ~= wasHidden then + Protocol.fieldHiddenChanged = true end - offset = 1 - else - -- All data arrived in one chunk, operate directly on data - Protocol.fieldData = data - offset = 5 - end - - if chunksRemain > 0 then - Protocol.fieldChunk = Protocol.fieldChunk + 1 - Protocol.expectChunksRemain = chunksRemain - 1 - else - -- Field data stream is now complete, process into a field - Protocol.loadQueue[#Protocol.loadQueue] = nil - - -- Hidden-bit changes rebuild the UI's cached list of visible fields, so - -- track it across the decode. - local wasHidden = field.hidden - -- Passing the old name makes the decoder skip its read and reuse that string, - -- which is only safe while no reload has flagged the name as possibly changed - local cachedName = (not field.nameStale and not field.reloading) and field.name or nil - if fields.decodeEntry(field, fieldId, Protocol.fieldData, offset, cachedName) then - field.nameStale = nil - field.reloading = nil - if field.hidden ~= wasHidden then - Protocol.fieldHiddenChanged = true - end - if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE then - -- A command that was actively running just finished (or was cancelled): - -- re-read its same-level fields so the current page reflects any values - -- the command changed. The guard limits this to the active command -- - -- routine loads of idle command fields while browsing (fieldPopup is - -- nil) must not trigger a reload. - if Protocol.fieldPopup == field then - Protocol.reloadRelatedFields(field) - end - Protocol.fieldPopup = nil + if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE then + -- A command that was actively running just finished (or was cancelled): + -- re-read its same-level fields so the current page reflects any values + -- the command changed. The guard limits this to the active command -- + -- routine loads of idle command fields while browsing (fieldPopup is + -- nil) must not trigger a reload. + if Protocol.fieldPopup == field then + Protocol.reloadRelatedFields(field) end + Protocol.fieldPopup = nil + end - -- Auto-queue children for root folder (field 0) and during background preloading. - if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then - for i = #field.children, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] - end + -- Auto-queue children for root folder (field 0) and during background preloading. + if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then + for i = #field.children, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] end end - - Protocol.fieldChunk = 0 - Protocol.fieldData = nil end end @@ -414,8 +393,7 @@ function Protocol.parseElrsInfoMessage(data) return end if status.id ~= Protocol.deviceId then - Protocol.fieldData = nil - Protocol.fieldChunk = 0 + fields.resetChunks(Protocol) return end @@ -501,10 +479,7 @@ function Protocol.tick() Protocol.linkstatTimeout = time + 100 elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then if #Protocol.loadQueue > 0 then - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_READ, - { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk } - ) + fields.sendRead(Protocol, Protocol.loadQueue[#Protocol.loadQueue]) Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() else Protocol.backgroundLoading = false From eaf8aff51d69468876ecbc63a61dd2ff87f52b5a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:40:39 +0300 Subject: [PATCH 158/218] Serve VTX Admin discovery from the shared field engine The widget's private parse helpers are gone: parseChildIds and parseFieldName read at hardcoded offsets 6/7 and parseFieldType dropped the hidden bit, all on the assumption that an entry always arrives in one frame. Discovery now runs Protocol (its session table, addressed at ADDRESS_TX/ADDRESS_HANDSET_ELRS) through crsf_fields reassemble + decodeEntry, and reads go out via sendRead, which carries the chunk index the old sendParameterRead pinned to 0 -- so chunked entries work without a new loop: an incomplete entry leaves the item queued (or the folder read armed) and the existing tick retry sends the follow-up. discoveredFields, previously write-only, is now the decode-target cache that gives decodeEntry its per-field identity. Reassembly gained a guard the tool never needed. Under the poll() fan-out every instance sees every answer, so when several instances each request the same field, the extra copies of a multi-chunk entry's final chunk arrive after a session has already completed it -- and their header is indistinguishable from a fresh single-frame entry, so they decoded as garbage. Reproduced with two widget zones at maxPacketBytes 22: both read "VTX Off" instead of "R3". reassemble() now records the field whose chunked entry just completed and swallows those trailing finals until the next request cycle. VTX.parseFolderName strips the pit-mode aux arrow by suffix: the shared decoder translates the firmware's one-byte 0xC0/0xC1 into the multi-byte CHAR_UP/CHAR_DOWN glyphs, which the old fixed -2 slice would have cut mid-glyph. Write-queue entries carry {id, value} so sendWriteInt consumes them directly; "Send VTx" queues CMD_CLICK the same way, byte-identical on the wire. Verified on TX16S with two widget instances: discovery to READY, a Band R->A change writing field 11 and both instances picking up the folder read-back, Send VTx sending one CMD_CLICK, and the resume-from-tool path issuing its single folder re-read. At maxPacketBytes 22 both instances now show R3, and the tool's field dump stays byte-identical to the baseline. --- src/SCRIPTS/ELRS/crsf_fields.lua | 24 +++++ src/WIDGETS/ELRSVTXAdmin/loadable.lua | 133 ++++++++++++-------------- src/WIDGETS/ELRSVTXAdmin/main.lua | 7 +- 3 files changed, 92 insertions(+), 72 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_fields.lua index a6f7019..9501539 100644 --- a/src/SCRIPTS/ELRS/crsf_fields.lua +++ b/src/SCRIPTS/ELRS/crsf_fields.lua @@ -201,6 +201,8 @@ local handlers = { -- fieldData reassembly buffer for the in-flight entry -- fieldDataId the field id the buffer belongs to -- expectChunksRemain duplicate-frame guard +-- fieldDone field whose multi-chunk entry just completed, to +-- swallow the other consumers' trailing final chunks -- Consumers initialize fieldChunk = 0 and expectChunksRemain = -1 alongside -- their deviceId/handsetId addressing bytes. -- ============================================================================ @@ -211,6 +213,7 @@ function Fields.resetChunks(session) session.fieldChunk = 0 session.fieldData = nil session.fieldDataId = nil + session.fieldDone = nil end --- Feed one PARAMETER_SETTINGS_ENTRY frame into the session. @@ -242,6 +245,22 @@ function Fields.reassemble(session, data, expectedFieldId) return nil end local chunksRemain = data[4] + -- Trailing duplicates of a multi-chunk entry: when several consumers each + -- request the same field, every session sees every answer, and the extra + -- copies of the final chunk arrive back to back after this session already + -- completed the entry. Their header is indistinguishable from a fresh + -- single-frame entry, so they would decode as garbage. Swallow them until + -- a new request cycle starts -- traffic for another field, or our own + -- sendRead, both of which clear fieldDone. + if session.fieldDone then + if session.fieldDone == data[3] then + if chunksRemain == 0 and not session.fieldData then + return nil + end + else + session.fieldDone = nil + end + end -- chunksRemain changed while data is buffered: duplicate frame, drop it if session.fieldData and chunksRemain ~= session.expectChunksRemain then return nil @@ -270,7 +289,11 @@ function Fields.reassemble(session, data, expectedFieldId) return data[3] end + local wasChunked = session.fieldChunk > 0 Fields.resetChunks(session) + if wasChunked then + session.fieldDone = data[3] + end return data[3], buffer, offset end @@ -329,6 +352,7 @@ end -- @param session table with deviceId/handsetId and fieldChunk -- @param fieldId the field id to read function Fields.sendRead(session, fieldId) + session.fieldDone = nil crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, { session.deviceId, session.handsetId, fieldId, session.fieldChunk }) end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index bf31277..751f632 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -9,7 +9,7 @@ -- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H. -- --------------------------------------------------------------------------- -local zone, options, crsf = ... +local zone, options, crsf, fields = ... -- Forward declarations for modules (needed for cross-references) local VTX @@ -93,8 +93,18 @@ function VTX.parseFolderName(name) s.pitmode = true s.pitmodeAux = nil else + -- Strip the trailing up/down arrow: the shared decoder translates the + -- firmware's one-byte arrows into the (multi-byte) CHAR_UP/CHAR_DOWN + -- glyphs before the name reaches us. s.pitmode = false - s.pitmodeAux = string.sub(parts[4], 1, -2) + local part = parts[4] + if CHAR_UP and string.sub(part, -#CHAR_UP) == CHAR_UP then + s.pitmodeAux = string.sub(part, 1, -#CHAR_UP - 1) + elseif CHAR_DOWN and string.sub(part, -#CHAR_DOWN) == CHAR_DOWN then + s.pitmodeAux = string.sub(part, 1, -#CHAR_DOWN - 1) + else + s.pitmodeAux = string.sub(part, 1, -2) + end end return true end @@ -144,6 +154,16 @@ Protocol = { state = 0, -- STATE_INIT statusText = "Initializing...", + -- crsf_fields.lua session: addressing bytes plus the reassembly keys the + -- library manages (fieldChunk doubles as the next chunk index sendRead + -- carries, giving multi-chunk entries their follow-up reads). + deviceId = crsf.CONST.ADDRESS_TX, + handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, + fieldChunk = 0, + fieldData = nil, + fieldDataId = nil, + expectChunksRemain = -1, + -- Discovery loadQueue = {}, rootChildren = {}, @@ -203,75 +223,46 @@ end function Protocol.armFolderRead(delay) Protocol.folderReadPending = getTime() + delay Protocol.folderReadAttempts = 0 + -- A fresh read must not inherit chunk state from an interrupted one (e.g. + -- resume from suspension mid-entry). + fields.resetChunks(Protocol) end -- ============================================================================ --- Protocol: Parse helpers +-- Protocol: Response handler (registered on singleton dispatcher) -- ============================================================================ ---- Parse child IDs from a FOLDER-type PARAMETER_SETTINGS_ENTRY payload. -function Protocol.parseChildIds(data) - local ids = {} - local off = 7 - while data[off] ~= nil and data[off] ~= 0 do - off = off + 1 +function Protocol.onSettingsEntry(data) + -- Passive fan-out mode: accept any field from our device (data[3] as the + -- expected id) -- sibling widget instances depend on seeing responses they + -- did not request. The library gates on Protocol.deviceId and keeps + -- cross-field continuations out of an in-flight reassembly buffer. + local fieldId, buffer, offset = fields.reassemble(Protocol, data, data[3]) + if not fieldId then + return end - off = off + 1 -- skip null terminator - while data[off] ~= nil and data[off] ~= crsf.CONST.FIELD_LIST_END do - ids[#ids + 1] = data[off] - off = off + 1 + if not buffer then + -- Chunk consumed: hurry the follow-up read, which carries the updated + -- chunk index. The device is answering, so no retry attempt is burned. + if Protocol.state >= Protocol.STATE_DISCOVER_ROOT and Protocol.state <= Protocol.STATE_DISCOVER_VTX then + Protocol.fieldTimeout = 0 + elseif Protocol.folderReadPending then + Protocol.folderReadPending = getTime() + Protocol.folderReadAttempts = 0 + end + return end - return ids -end ---- Parse field name from a PARAMETER_SETTINGS_ENTRY payload. -function Protocol.parseFieldName(data) - local off = 7 - local startOff = off - while data[off] ~= nil and data[off] ~= 0 do - off = off + 1 + local field = Protocol.discoveredFields[fieldId] + if not field then + field = {} + Protocol.discoveredFields[fieldId] = field end - local chars = {} - for i = startOff, off - 1 do - chars[#chars + 1] = string.char(data[i]) - end - return table.concat(chars) -end - ---- Parse field type from a PARAMETER_SETTINGS_ENTRY payload. -function Protocol.parseFieldType(data) - return bit32.band(data[6] or 0, 0x7F) -end - --- ============================================================================ --- Protocol: Sending --- ============================================================================ - -function Protocol.sendParameterRead(fieldId) - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, { crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET_ELRS, fieldId, 0 }) -end - -function Protocol.sendParameterWrite(fieldId, value) - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET_ELRS, fieldId, value } - ) -end - --- ============================================================================ --- Protocol: Response handler (registered on singleton dispatcher) --- ============================================================================ - -function Protocol.onSettingsEntry(data) - if data[2] ~= crsf.CONST.ADDRESS_TX then + if not fields.decodeEntry(field, fieldId, buffer, offset) then return end - - local fieldId = data[3] - local fieldType = Protocol.parseFieldType(data) - local fieldName = Protocol.parseFieldName(data) - - Protocol.discoveredFields[fieldId] = { name = fieldName, type = fieldType } + local fieldType = field.type + local fieldName = field.name -- Pop the field from load queue if it matches if #Protocol.loadQueue > 0 and Protocol.loadQueue[#Protocol.loadQueue] == fieldId then @@ -283,7 +274,7 @@ function Protocol.onSettingsEntry(data) if st == Protocol.STATE_DISCOVER_ROOT then if fieldId == 0 and fieldType == crsf.CONST.FIELD_FOLDER then - Protocol.rootChildren = Protocol.parseChildIds(data) + Protocol.rootChildren = field.children or {} for i = #Protocol.rootChildren, 1, -1 do Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.rootChildren[i] end @@ -293,7 +284,7 @@ function Protocol.onSettingsEntry(data) elseif st == Protocol.STATE_DISCOVER_CHILDREN then if fieldType == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then VTX.ids.folder = fieldId - Protocol.vtxChildren = Protocol.parseChildIds(data) + Protocol.vtxChildren = field.children or {} VTX.parseFolderName(fieldName) for i = #Protocol.vtxChildren, 1, -1 do Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.vtxChildren[i] @@ -371,7 +362,7 @@ function Protocol.tick() then if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then local fieldId = Protocol.loadQueue[#Protocol.loadQueue] - Protocol.sendParameterRead(fieldId) + fields.sendRead(Protocol, fieldId) Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() end elseif st == Protocol.STATE_READY then @@ -379,7 +370,7 @@ function Protocol.tick() if Protocol.folderReadAttempts < Protocol.FOLDER_READ_RETRIES then Protocol.folderReadAttempts = Protocol.folderReadAttempts + 1 Protocol.folderReadPending = now + Protocol.fieldResponseTimeout() - Protocol.sendParameterRead(VTX.ids.folder) + fields.sendRead(Protocol, VTX.ids.folder) else Protocol.folderReadPending = nil end @@ -388,8 +379,8 @@ function Protocol.tick() if Protocol.writeIdx <= #Protocol.writeQueue then if now - Protocol.lastWriteTime >= 5 then -- 50ms local entry = Protocol.writeQueue[Protocol.writeIdx] - print(table.concat({ "VTXAdmin: writing field=", entry[1], " val=", entry[2] })) - Protocol.sendParameterWrite(entry[1], entry[2]) + print(table.concat({ "VTXAdmin: writing field=", entry.id, " val=", entry.value })) + fields.sendWriteInt(Protocol, entry) Protocol.lastWriteTime = now Protocol.writeIdx = Protocol.writeIdx + 1 end @@ -443,19 +434,19 @@ function Protocol.writeConfig() })) if d.band ~= s.band then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.band, d.band } + Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.band, value = d.band } end if d.channel ~= s.channel then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.channel, d.channel } + Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.channel, value = d.channel } end if d.power ~= s.power then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.power, d.power } + Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.power, value = d.power } end local desiredPit = d.pitmode local currentPit = s.pitmode and 1 or 0 if desiredPit ~= currentPit then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.pitmode, desiredPit } + Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.pitmode, value = desiredPit } end print(table.concat({ "VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)" })) @@ -475,7 +466,7 @@ function Protocol.pushToVtx() end print("VTXAdmin: pushToVtx() - queuing Send VTx command") - Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.send, crsf.CONST.CMD_CLICK } + Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.send, value = crsf.CONST.CMD_CLICK } if Protocol.state ~= Protocol.STATE_SENDING then Protocol.writeIdx = 1 diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index 95e344e..29bb154 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -16,9 +16,14 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end + if not _crsfFieldsSingleton then + local getFields = loadScript("/SCRIPTS/ELRS/crsf_fields.lua") + ---@diagnostic disable-next-line: need-check-nil + _crsfFieldsSingleton = getFields(_crsfSingleton) + end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton) + return loadable(zone, options, _crsfSingleton, _crsfFieldsSingleton) end local function refresh(widget, event, touchState) From 6aea9ede82086a28f39332a35c687e8916d266c1 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:41:34 +0300 Subject: [PATCH 159/218] Name the ELRS library files after the protocol they speak elrsinfo.lua becomes crsf_elrsinfo.lua, so SCRIPTS/ELRS holds crsf.lua, crsf_fields.lua and crsf_elrsinfo.lua under one prefix, with shim.lua as the only non-CRSF file. The singleton global (_elrsInfoSingleton) and the module table are unchanged. Verified: the telemetry widget loads /SCRIPTS/ELRS/crsf_elrsinfo.lua and renders link stats on TX16S. --- src/SCRIPTS/ELRS/crsf.lua | 2 +- src/SCRIPTS/ELRS/{elrsinfo.lua => crsf_elrsinfo.lua} | 0 src/WIDGETS/ELRSTelemetry/loadable.lua | 2 +- src/WIDGETS/ELRSTelemetry/main.lua | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename src/SCRIPTS/ELRS/{elrsinfo.lua => crsf_elrsinfo.lua} (100%) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 40a93e0..daf4b54 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -210,7 +210,7 @@ function CRSF:poll() -- about the link. Derived after the drain so a frame from a dying connection -- is dispatched before consumers observe the flip; handlers that keep -- per-connection state key their resets off that ordering (see - -- /SCRIPTS/ELRS/elrsinfo.lua). + -- /SCRIPTS/ELRS/crsf_elrsinfo.lua). self.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 end diff --git a/src/SCRIPTS/ELRS/elrsinfo.lua b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua similarity index 100% rename from src/SCRIPTS/ELRS/elrsinfo.lua rename to src/SCRIPTS/ELRS/crsf_elrsinfo.lua diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index b8d206d..905f7b9 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -3,7 +3,7 @@ -- Loaded via loadScript() from ELRSTelemetry/main.lua -- -- -- -- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- --- singleton for transport and elrsinfo.lua for device info/model match.-- +-- singleton for transport and crsf_elrsinfo.lua for device info/match. -- -- -- -- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- --------------------------------------------------------------------------- diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index 67e5e08..ee119c2 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -17,7 +17,7 @@ local function create(zone, options) _crsfSingleton = getCRSF() end if not _elrsInfoSingleton then - local getElrsInfo = loadScript("/SCRIPTS/ELRS/elrsinfo.lua") + local getElrsInfo = loadScript("/SCRIPTS/ELRS/crsf_elrsinfo.lua") ---@diagnostic disable-next-line: need-check-nil _elrsInfoSingleton = getElrsInfo(_crsfSingleton) end From 137e56c446e91771e16829d2a43b3e02e4938d0e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:42:46 +0300 Subject: [PATCH 160/218] Read the tool's link state off Protocol directly hasTelemetry(), isModelMismatch() and hasCriticalError() only returned Protocol.connected / .modelMismatch / .criticalError, which the UIs and main.lua already read as plain fields elsewhere. The call sites now use those fields; a caller that needs a function-valued property (an LVGL text/visible callback) can close over them at the point of use. Verified on TX16S: the model_mismatch scenario still raises its dialog and the subtitle still reports telemetry state. --- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 19 +++---------------- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 4 ++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 6 +++--- 3 files changed, 8 insertions(+), 21 deletions(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 2feb3bc..91d41df 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -57,19 +57,6 @@ local Protocol = { hadTelemetry = false, } --- Telemetry is being received from the RX (decoded ELRS status connected bit) -function Protocol.hasTelemetry() - return Protocol.connected -end - -function Protocol.isModelMismatch() - return Protocol.modelMismatch -end - -function Protocol.hasCriticalError() - return Protocol.criticalError -end - -- Response timeout for PARAMETER_READ: -- 0.5s for local TX module, 5s for remote devices relayed over air link. function Protocol.fieldResponseTimeout() @@ -448,11 +435,11 @@ end function Protocol.tick() -- Ping on telemetry transition (device may have changed) - local hasTelemetry = Protocol.hasTelemetry() - if hasTelemetry and not Protocol.hadTelemetry then + local connected = Protocol.connected + if connected and not Protocol.hadTelemetry then crsf:pingDevices() end - Protocol.hadTelemetry = hasTelemetry + Protocol.hadTelemetry = connected local time = getTime() -- Periodic ping for initial device discovery diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 70527c2..537f24f 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -173,7 +173,7 @@ function UI.render(event, _touchState) end -- Model mismatch alert (full-screen, blocks normal rendering) - if Protocol.isModelMismatch() and not UI.warningDismissedAt then + if Protocol.modelMismatch and not UI.warningDismissedAt then if event == EVT_VIRTUAL_ENTER then UI.warningDismissedAt = getTime() UI.forceRedraw = true @@ -449,7 +449,7 @@ function UI.drawTitle() local barHeight = 9 local goodBadPkt = "" if Protocol.receivedPackets then - local state = Protocol.hasTelemetry() and "C" or "-" + local state = Protocol.connected and "C" or "-" goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 0a9cf0e..4e8c02d 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -573,14 +573,14 @@ local function handleWarning() end if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then if not UI.warningDialog and not UI.warningDismissedAt then - if Protocol.isModelMismatch() then + if Protocol.modelMismatch then UI.warningDialog = ModelMismatchDialog.show(function() UI.warningDismissedAt = getTime() UI.invalidate() end, function() App.shouldExit = true end) - elseif Protocol.hasCriticalError() then + elseif Protocol.criticalError then Dialogs.showMessage({ title = "Warning", message = Protocol.elrsFlagsInfo, @@ -641,7 +641,7 @@ function UI.getSubtitle() local subtitle = "" if Protocol.receivedPackets then - local state = Protocol.hasTelemetry() and "Telemetry OK" or "No telemetry" + local state = Protocol.connected and "Telemetry OK" or "No telemetry" subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) end From 381a8b0a4f9eac6874d26ca4cb0c041b61257d57 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 15:43:44 +0300 Subject: [PATCH 161/218] Document the field engine boundaries Adds crsf_fields.lua to the shared-library table with the non-mutating rule and the two receive models reassemble() serves, records the protocol.lua row as policy over that engine, and lists the new critical_error scenario and the maxPacketBytes chunking knob. README's SD card tree picks up both renamed and new library files. --- README.md | 5 +++-- docs/development.md | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3576c60..9e6f92f 100644 --- a/README.md +++ b/README.md @@ -19,12 +19,13 @@ When done, your SD card should contain: SCRIPTS/ ELRS/ crsf.lua -- shared CRSF protocol library - elrsinfo.lua -- TX module info state (telemetry widget) + crsf_fields.lua -- parameter field codec (tool, VTX Admin) + crsf_elrsinfo.lua -- TX module info state (telemetry widget) shim.lua -- BW compatibility shim TOOLS/ ExpressLRS/ main.lua -- entry point - protocol.lua -- CRSF protocol handling + protocol.lua -- device discovery and field policy navigation.lua -- folder navigation ui/ lvgl.lua -- color LCD UI (LVGL) diff --git a/docs/development.md b/docs/development.md index a6c7b2b..b7e9c93 100644 --- a/docs/development.md +++ b/docs/development.md @@ -35,7 +35,7 @@ Run `make help` to list all targets. The Makefile groups them into three categor | Module | Purpose | |--------|---------| | `main.lua` | Entry point and run-loop orchestrator | -| `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | +| `protocol.lua` | Device discovery, load-queue and command-popup policy over the shared field engine | | `navigation.lua` | Folder and device navigation stack | | `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | | `ui/lcd.lua` | BW LCD interface (text cursor, popups) | @@ -45,9 +45,16 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | Module | Purpose | |--------|---------| | `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | -| `SCRIPTS/ELRS/elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | +| `SCRIPTS/ELRS/crsf_fields.lua` | Opt-in parameter-field engine: `PARAMETER_SETTINGS_ENTRY` chunk reassembly and per-type decode, `PARAMETER_READ`/`WRITE` senders, command-step and suppress-critical-errors frames. Stateless -- callers pass a session table. Loaded by the tool and the VTX Admin widget | +| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | +`crsf_fields.lua` never mutates a frame's data table: `crsf:poll()` hands the same table to every +registered handler, so an in-place decode would corrupt the frame for sibling handlers -- the hazard +`crsf.lua`'s `fieldGetString` documents. Its `reassemble()` serves both receive models: the tool +drains `crsf.pop()` itself and passes the field id it is waiting for, while widgets register a +handler and pass `data[3]` to accept any field from their device. + ## CRSF Simulator The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. @@ -76,3 +83,9 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | `single_antenna` | TX + RX connected on a receiver with one RF path. `2RSS` is pinned to 0, so the telemetry widget reports no diversity. | | `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | | `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | +| `critical_error` | TX + RX connected with a critical baud-rate error flag. Triggers the warning screen; the suppress write clears it. | + +`config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset +link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as +`CRSFEndpoint::sendParameter` does, so lowering it -- real firmware shrinks it on slow baud rates in +`CRSFHandset::adjustMaxPacketSize` -- exercises chunk reassembly and the follow-up reads. From 612689dd16fbfe30995926585c613a865f0f11af Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 17:01:52 +0300 Subject: [PATCH 162/218] Fix the B&W edit commit calling a deleted saver Commit f04caf2 replaced the per-type save wrappers with the codec's sendWriteInt, but the B&W edit path kept calling Protocol.fieldIntSave, so committing any edited value crashed the tool with "attempt to call a nil value". Both numeric and text-selection edits go over sendWriteInt, matching the LVGL UI. --- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 537f24f..e78affe 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -522,7 +522,7 @@ function UI.handleEvent(event) elseif not field.disabled and ft <= crsf.CONST.FIELD_TEXT_SELECTION then UI.edit = not UI.edit if not UI.edit then - Protocol.fieldIntSave(field) + fields.sendWriteInt(Protocol, field) Protocol.reloadRelatedFields(field) end end From 1253c17eb031d93628532c1a1ead944223a786f4 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 17:05:57 +0300 Subject: [PATCH 163/218] Decode link-layer strings without touching the frame poll() hands one data table to every handler registered for a frame type, so the decoders must not write into it. The string reader now collects chars into its own buffer instead of converting frame bytes in place, which lets any handler decode the same frame independently of registration order. --- src/SCRIPTS/ELRS/crsf.lua | 27 ++++++++++++--------------- src/SCRIPTS/ELRS/crsf_elrsinfo.lua | 3 --- src/SCRIPTS/ELRS/crsf_fields.lua | 3 +-- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index daf4b54..28bfce5 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -218,36 +218,34 @@ end -- Shared helpers -- ============================================================================ ---- Parse a null-terminated string from a CRSF data array. --- Modifies data in-place (bytes -> chars) for efficiency: poll() hands the --- same data table to every handler registered for a frame type, so a handler --- running after this one sees 1-char strings, not bytes, over --- [off, nextOffset - 2]. +--- Read a null-terminated string from a CRSF data array without mutating it. +-- poll() hands the same data table to every handler registered for a frame +-- type, so decoding must never write into the frame. -- @param data array of byte values -- @param off 1-based start offset -- @return string, nextOffset -function CRSF:fieldGetString(data, off) - local startOff = off +local function readString(data, off) + local parts = {} local b = data[off] while b and b ~= 0 do - data[off] = string.char(b) + parts[#parts + 1] = string.char(b) off = off + 1 b = data[off] end - return shim.tableConcat(data, nil, startOff, off - 1), off + 1 + return shim.tableConcat(parts), off + 1 end --- Decode a DEVICE_INFO (0x29) frame. -- Payload after [dest, src]: name (null-terminated), serial (4B BE), -- hwVer (4B), swVer (4B, low three bytes are maj.min.rev), fieldCount (1B), --- parameter protocol version (1B). Consumes the name bytes in place (see --- fieldGetString). No address gate: callers gate on the returned id. +-- parameter protocol version (1B). No address gate: callers gate on the +-- returned id. -- @param data array of byte values -- @return table with id (source address), name, isElrs (true/nil), fieldCount, -- vMaj, vMin, vRev -- or nil if the frame is shorter than the layout function CRSF:decodeDeviceInfo(data) local id = data[2] - local name, off = self:fieldGetString(data, 3) + local name, off = readString(data, 3) if data[off + 12] == nil then return nil -- shorter than the fixed layout; the caller's ping retries end @@ -264,8 +262,7 @@ function CRSF:decodeDeviceInfo(data) end --- Decode an ELRS_STATUS (0x2E) frame (the answer to requestElrsStatus()). --- Consumes the warning bytes in place (see fieldGetString). No address gate: --- callers gate on the returned id. +-- No address gate: callers gate on the returned id. -- @param data array of byte values -- @return table with id (source address), lostPackets, receivedPackets, flags -- (raw byte, for threshold checks), connected / modelMismatch / @@ -277,7 +274,7 @@ function CRSF:decodeElrsStatus(data) return nil end local flags = data[6] - local warning = self:fieldGetString(data, 7) + local warning = readString(data, 7) return { id = data[2], lostPackets = data[3], diff --git a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua index b5a61e8..d0963cc 100644 --- a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua +++ b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua @@ -289,9 +289,6 @@ local function onElrsStatus(data) ElrsInfo.modelMismatch = status.modelMismatch end --- Sole byte-level consumer of these frame types in this Lua state: the --- decoders consume string bytes in place, so a handler registered behind --- these would see decoded chars, not bytes. crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_fields.lua index 9501539..b5c0fb3 100644 --- a/src/SCRIPTS/ELRS/crsf_fields.lua +++ b/src/SCRIPTS/ELRS/crsf_fields.lua @@ -9,8 +9,7 @@ -- -- -- Purity rule: nothing in this file mutates a frame data table. -- -- crsf:poll() hands the same table to every registered handler, so an -- --- in-place decode here would corrupt the frame for sibling handlers -- --- (the hazard crsf.lua's fieldGetString documents). -- +-- in-place decode here would corrupt the frame for sibling handlers. -- -- -- -- Loaded via loadScript("/SCRIPTS/ELRS/crsf_fields.lua")(crsf). -- -- Returns the codec table directly. -- From 5152895a4125d9b90571c4462eaca5d4bda10ab1 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 17:21:26 +0300 Subject: [PATCH 164/218] Split the parameter codec from transport as crsf_params crsf_params.lua is a pure codec: reassembly runs over an explicit caller-owned rx table ({ chunk, data, dataId, expect, done }) with the device address passed in, and the frame builders return (frameType, payload) for the caller to push instead of pushing themselves. Callers no longer lend the codec a slice of their own table -- the six flat reassembly keys and the addressing reads are gone, and every send site is crsf.push(encode*(...)). --- README.md | 2 +- docs/development.md | 12 +- .../ELRS/{crsf_fields.lua => crsf_params.lua} | 223 +++++++++--------- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 +- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 43 ++-- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 4 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 8 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 23 +- src/WIDGETS/ELRSVTXAdmin/main.lua | 8 +- 9 files changed, 171 insertions(+), 154 deletions(-) rename src/SCRIPTS/ELRS/{crsf_fields.lua => crsf_params.lua} (64%) diff --git a/README.md b/README.md index 9e6f92f..0c8f4d7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ When done, your SD card should contain: SCRIPTS/ ELRS/ crsf.lua -- shared CRSF protocol library - crsf_fields.lua -- parameter field codec (tool, VTX Admin) + crsf_params.lua -- parameter codec (tool, VTX Admin) crsf_elrsinfo.lua -- TX module info state (telemetry widget) shim.lua -- BW compatibility shim TOOLS/ diff --git a/docs/development.md b/docs/development.md index b7e9c93..56b8513 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,15 +45,15 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | Module | Purpose | |--------|---------| | `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | -| `SCRIPTS/ELRS/crsf_fields.lua` | Opt-in parameter-field engine: `PARAMETER_SETTINGS_ENTRY` chunk reassembly and per-type decode, `PARAMETER_READ`/`WRITE` senders, command-step and suppress-critical-errors frames. Stateless -- callers pass a session table. Loaded by the tool and the VTX Admin widget | +| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | -`crsf_fields.lua` never mutates a frame's data table: `crsf:poll()` hands the same table to every -registered handler, so an in-place decode would corrupt the frame for sibling handlers -- the hazard -`crsf.lua`'s `fieldGetString` documents. Its `reassemble()` serves both receive models: the tool -drains `crsf.pop()` itself and passes the field id it is waiting for, while widgets register a -handler and pass `data[3]` to accept any field from their device. +Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table: `crsf:poll()` hands the +same table to every registered handler, so an in-place decode would corrupt the frame for sibling +handlers. `reassemble()` serves both receive models: the tool drains `crsf.pop()` itself and passes +the field id it is waiting for, while widgets register a handler and pass `data[3]` to accept any +field from their device. ## CRSF Simulator diff --git a/src/SCRIPTS/ELRS/crsf_fields.lua b/src/SCRIPTS/ELRS/crsf_params.lua similarity index 64% rename from src/SCRIPTS/ELRS/crsf_fields.lua rename to src/SCRIPTS/ELRS/crsf_params.lua index b5c0fb3..4942b41 100644 --- a/src/SCRIPTS/ELRS/crsf_fields.lua +++ b/src/SCRIPTS/ELRS/crsf_params.lua @@ -1,17 +1,19 @@ --------------------------------------------------------------------------- --- CRSF Parameter-Field Codec -- +-- CRSF Parameter Codec -- -- -- --- Stateless codecs for CRSF parameter fields: byte getters and the -- --- per-type PARAMETER_SETTINGS_ENTRY (0x2B) payload decoders. Opt-in: -- --- only consumers that read or write parameter fields load it, so -- --- telemetry-only widgets never pay for it. Policy -- load queues, -- --- command popups, reload decisions -- stays with the caller. -- +-- Pure codecs for CRSF parameter traffic: byte getters, the per-type -- +-- PARAMETER_SETTINGS_ENTRY (0x2B) payload decoders, chunk reassembly -- +-- over a caller-owned rx-state table, and frame encoders that return -- +-- (frameType, payload) for the caller to push. Opt-in: only consumers -- +-- that read or write parameter fields load it, so telemetry-only -- +-- widgets never pay for it. Policy -- load queues, command popups, -- +-- reload decisions, transport -- stays with the caller. -- -- -- -- Purity rule: nothing in this file mutates a frame data table. -- -- crsf:poll() hands the same table to every registered handler, so an -- -- in-place decode here would corrupt the frame for sibling handlers. -- -- -- --- Loaded via loadScript("/SCRIPTS/ELRS/crsf_fields.lua")(crsf). -- +-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf). -- -- Returns the codec table directly. -- --------------------------------------------------------------------------- @@ -19,7 +21,7 @@ local crsf = ... local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -local Fields = {} +local Params = {} -- ============================================================================ -- Byte getters @@ -30,7 +32,7 @@ local Fields = {} -- @param offset 1-based start offset -- @param size width in bytes -- @return number -function Fields.readValue(data, offset, size) +function Params.readValue(data, offset, size) local result = 0 for i = 0, size - 1 do result = bit32.lshift(result, 8) + data[offset + i] @@ -50,7 +52,7 @@ end -- @param isOpts truthy to split on ';' and return a table of options -- @return string|table result, number nextOffset, number optCount (count of -- non-empty options; 0 when cached or not isOpts) -function Fields.readStringOrOpts(data, offset, last, isOpts) +function Params.readStringOrOpts(data, offset, last, isOpts) local r = last or (isOpts and {}) local optParts = {} local vcnt = 0 @@ -87,10 +89,10 @@ end -- ============================================================================ local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = Fields.readValue(data, offset, size) - field.min = Fields.readValue(data, offset + size, size) - field.max = Fields.readValue(data, offset + 2 * size, size) - local unit = Fields.readStringOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + field.value = Params.readValue(data, offset, size) + field.min = Params.readValue(data, offset + size, size) + field.max = Params.readValue(data, offset + 2 * size, size) + local unit = Params.readStringOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) field.unit = (unit ~= "") and unit or nil if size ~= 1 then field.size = size @@ -121,7 +123,7 @@ local function fieldFloatLoad(field, data, offset) if field.prec > 3 then field.prec = 3 end - field.step = Fields.readValue(data, offset + 17, 4) + field.step = Params.readValue(data, offset + 17, 4) field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" }) field.prec = 10 ^ field.prec end @@ -130,7 +132,7 @@ local function fieldTextSelLoad(field, data, offset) local vcnt local oldValues = field.values local cached = field.dirty == nil and oldValues - field.values, offset, vcnt = Fields.readStringOrOpts(data, offset, cached, true) + field.values, offset, vcnt = Params.readStringOrOpts(data, offset, cached, true) if not cached then field.disabled = (vcnt <= 1) or nil -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) @@ -148,13 +150,13 @@ local function fieldTextSelLoad(field, data, offset) end end field.value = data[offset] - local unit = Fields.readStringOrOpts(data, offset + 4) + local unit = Params.readStringOrOpts(data, offset + 4) field.unit = (unit ~= "") and unit or nil field.dirty = nil end local function fieldStringLoad(field, data, offset) - field.value, offset = Fields.readStringOrOpts(data, offset) + field.value, offset = Params.readStringOrOpts(data, offset) if #data >= offset then field.maxlen = data[offset] end @@ -163,7 +165,7 @@ end local function fieldCommandLoad(field, data, offset) field.status = data[offset] field.timeout = data[offset + 1] - local info = Fields.readStringOrOpts(data, offset + 2) + local info = Params.readStringOrOpts(data, offset + 2) field.info = (info ~= "") and info or nil end @@ -195,83 +197,83 @@ local handlers = { -- -- PARAMETER_SETTINGS_ENTRY payloads larger than the handset link's frame -- limit arrive in chunks (CRSFEndpoint::sendParameter). Reassembly state --- lives in a caller-owned session table; the library manages these keys: --- fieldChunk next chunk index to request (readable; do not write) --- fieldData reassembly buffer for the in-flight entry --- fieldDataId the field id the buffer belongs to --- expectChunksRemain duplicate-frame guard --- fieldDone field whose multi-chunk entry just completed, to --- swallow the other consumers' trailing final chunks --- Consumers initialize fieldChunk = 0 and expectChunksRemain = -1 alongside --- their deviceId/handsetId addressing bytes. +-- lives in a caller-owned rx table with these keys: +-- chunk next chunk index to request (readable; do not write) +-- data reassembly buffer for the in-flight entry +-- dataId the field id the buffer belongs to +-- expect duplicate-frame guard (chunks expected to remain) +-- done field whose multi-chunk entry just completed, to swallow the +-- other consumers' trailing final chunks +-- Consumers initialize rx = { chunk = 0, expect = -1 }. -- ============================================================================ --- Abandon any in-flight reassembly. --- @param session the session table -function Fields.resetChunks(session) - session.fieldChunk = 0 - session.fieldData = nil - session.fieldDataId = nil - session.fieldDone = nil +-- @param rx the reassembly-state table +function Params.resetChunks(rx) + rx.chunk = 0 + rx.data = nil + rx.dataId = nil + rx.done = nil end ---- Feed one PARAMETER_SETTINGS_ENTRY frame into the session. +--- Feed one PARAMETER_SETTINGS_ENTRY frame into the reassembly. -- expectedFieldId selects the consumption model: a caller waiting on one -- specific field passes its id (nil while idle drops everything), a caller -- listening passively under the poll() fan-out passes data[3] to accept any --- field from session.deviceId -- the fieldDataId gate then keeps a --- sibling-elicited entry for another field out of an in-flight buffer. +-- field from deviceId -- the dataId gate then keeps a sibling-elicited entry +-- for another field out of an in-flight buffer. -- Never mutates data, and never retains it: the single-frame fast path -- returns data itself as the buffer, valid only for the current call. --- @param session the session table +-- @param rx the reassembly-state table +-- @param deviceId the device address answers must come from -- @param data the frame's byte array -- @param expectedFieldId the field id to accept -- @return fieldId, buffer, offset entry complete; buffer[offset] is the -- parent byte, ready for decodeEntry -- @return fieldId chunk consumed, more expected -- send the --- next read, which carries the updated session.fieldChunk +-- next read, which carries the updated rx.chunk -- @return nil frame dropped (wrong device or field, -- cross-field continuation, duplicate chunk) -function Fields.reassemble(session, data, expectedFieldId) +function Params.reassemble(rx, deviceId, data, expectedFieldId) -- Another device answered, or this is not the awaited field: drop any -- partial data - if data[2] ~= session.deviceId or data[3] ~= expectedFieldId then - Fields.resetChunks(session) + if data[2] ~= deviceId or data[3] ~= expectedFieldId then + Params.resetChunks(rx) return nil end -- An in-flight buffer only accepts continuation frames for its own field - if session.fieldData and session.fieldDataId ~= data[3] then + if rx.data and rx.dataId ~= data[3] then return nil end local chunksRemain = data[4] -- Trailing duplicates of a multi-chunk entry: when several consumers each - -- request the same field, every session sees every answer, and the extra - -- copies of the final chunk arrive back to back after this session already - -- completed the entry. Their header is indistinguishable from a fresh - -- single-frame entry, so they would decode as garbage. Swallow them until - -- a new request cycle starts -- traffic for another field, or our own - -- sendRead, both of which clear fieldDone. - if session.fieldDone then - if session.fieldDone == data[3] then - if chunksRemain == 0 and not session.fieldData then + -- request the same field, every rx sees every answer, and the extra copies + -- of the final chunk arrive back to back after this rx already completed + -- the entry. Their header is indistinguishable from a fresh single-frame + -- entry, so they would decode as garbage. Swallow them until a new request + -- cycle starts -- traffic for another field, or our own encodeRead, both + -- of which clear done. + if rx.done then + if rx.done == data[3] then + if chunksRemain == 0 and not rx.data then return nil end else - session.fieldDone = nil + rx.done = nil end end -- chunksRemain changed while data is buffered: duplicate frame, drop it - if session.fieldData and chunksRemain ~= session.expectChunksRemain then + if rx.data and chunksRemain ~= rx.expect then return nil end local buffer local offset -- If data is chunked, copy it to the persistent buffer - if chunksRemain > 0 or session.fieldChunk > 0 then - session.fieldData = session.fieldData or {} - session.fieldDataId = data[3] - buffer = session.fieldData + if chunksRemain > 0 or rx.chunk > 0 then + rx.data = rx.data or {} + rx.dataId = data[3] + buffer = rx.data for i = 5, #data do buffer[#buffer + 1] = data[i] end @@ -283,15 +285,15 @@ function Fields.reassemble(session, data, expectedFieldId) end if chunksRemain > 0 then - session.fieldChunk = session.fieldChunk + 1 - session.expectChunksRemain = chunksRemain - 1 + rx.chunk = rx.chunk + 1 + rx.expect = chunksRemain - 1 return data[3] end - local wasChunked = session.fieldChunk > 0 - Fields.resetChunks(session) + local wasChunked = rx.chunk > 0 + Params.resetChunks(rx) if wasChunked then - session.fieldDone = data[3] + rx.done = data[3] end return data[3], buffer, offset end @@ -314,7 +316,7 @@ end -- readStringOrOpts); nil decodes it fresh -- @return field, or nil when the entry is shorter than parent + type + one -- name byte (the caller should still drop it from its queue) -function Fields.decodeEntry(field, fieldId, buffer, offset, cachedName) +function Params.decodeEntry(field, fieldId, buffer, offset, cachedName) -- Need at least parent + type + one name byte for the entry to be usable if #buffer <= offset + 2 then return nil @@ -323,7 +325,7 @@ function Fields.decodeEntry(field, fieldId, buffer, offset, cachedName) field.parent = (buffer[offset] ~= 0) and buffer[offset] or nil field.type = bit32.band(buffer[offset + 1], 0x7f) field.hidden = bit32.btest(buffer[offset + 1], 0x80) or nil - field.name, offset = Fields.readStringOrOpts(buffer, offset + 2, cachedName) + field.name, offset = Params.readStringOrOpts(buffer, offset + 2, cachedName) local load = handlers[field.type + 1] if load then load(field, buffer, offset) @@ -338,30 +340,37 @@ function Fields.decodeEntry(field, fieldId, buffer, offset, cachedName) end -- ============================================================================ --- Frame senders +-- Frame encoders -- --- Every sender takes a caller-owned session table and reads its addressing --- bytes: session.deviceId (the target device) and session.handsetId (the --- reply-to address). Wire layouts match the tables in CRSFParameters.h. +-- Every encoder returns (frameType, payload) for the caller to push -- +-- crsf.push(Params.encodeRead(...)) -- so encoding stays free of transport. +-- deviceId is the target device, handsetId the reply-to address. Wire +-- layouts match the tables in CRSFParameters.h. -- ============================================================================ ---- Request one chunk of a field's PARAMETER_SETTINGS_ENTRY. The chunk index --- rides in session.fieldChunk, so follow-up reads of a chunked entry continue --- where reassemble() left off (0 requests a fresh entry). --- @param session table with deviceId/handsetId and fieldChunk --- @param fieldId the field id to read -function Fields.sendRead(session, fieldId) - session.fieldDone = nil - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_READ, { session.deviceId, session.handsetId, fieldId, session.fieldChunk }) +--- Encode a request for one chunk of a field's PARAMETER_SETTINGS_ENTRY. +-- Starts a new request cycle on rx: clears rx.done, and carries rx.chunk so +-- follow-up reads of a chunked entry continue where reassemble() left off +-- (0 requests a fresh entry). +-- @param rx the reassembly-state table +-- @param deviceId the target device address +-- @param handsetId the reply-to address +-- @param fieldId the field id to read +-- @return frameType, payload +function Params.encodeRead(rx, deviceId, handsetId, fieldId) + rx.done = nil + return crsf.CONST.FRAMETYPE_PARAMETER_READ, { deviceId, handsetId, fieldId, rx.chunk } end ---- Send a PARAMETER_WRITE carrying a field's integer value, big-endian at the --- field's width. field.size < 0 marks a signed field |size| bytes wide +--- Encode a PARAMETER_WRITE carrying a field's integer value, big-endian at +-- the field's width. field.size < 0 marks a signed field |size| bytes wide -- (decodeEntry's convention); negative values are re-encoded as two's -- complement. A missing size means 1 byte. --- @param session table with deviceId/handsetId --- @param field table with id, value and optional size -function Fields.sendWriteInt(session, field) +-- @param deviceId the target device address +-- @param handsetId the reply-to address +-- @param field table with id, value and optional size +-- @return frameType, payload +function Params.encodeWriteInt(deviceId, handsetId, field) local value = field.value local size = field.size or 1 if size < 0 then @@ -371,19 +380,21 @@ function Fields.sendWriteInt(session, field) end end - local frame = { session.deviceId, session.handsetId, field.id } + local frame = { deviceId, handsetId, field.id } for i = size - 1, 0, -1 do frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 end - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame end ---- Send a PARAMETER_WRITE carrying a field's string value, clamped to +--- Encode a PARAMETER_WRITE carrying a field's string value, clamped to -- field.maxlen (default 32), inner NULs stripped, null-terminated. --- @param session table with deviceId/handsetId --- @param field table with id, value and optional maxlen -function Fields.sendWriteString(session, field) - local frame = { session.deviceId, session.handsetId, field.id } +-- @param deviceId the target device address +-- @param handsetId the reply-to address +-- @param field table with id, value and optional maxlen +-- @return frameType, payload +function Params.encodeWriteString(deviceId, handsetId, field) + local frame = { deviceId, handsetId, field.id } local val = field.value or "" local maxlen = field.maxlen or 32 if #val > maxlen then @@ -396,34 +407,36 @@ function Fields.sendWriteString(session, field) end end frame[#frame + 1] = 0 - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame end ---- Send a command-step PARAMETER_WRITE: one byte from the commandStep_e +--- Encode a command-step PARAMETER_WRITE: one byte from the commandStep_e -- machine (crsf.CONST.CMD_CLICK / CMD_CONFIRMED / CMD_CANCEL / CMD_QUERY). --- @param session table with deviceId/handsetId --- @param fieldId the command field's id --- @param step the command step byte -function Fields.sendCommandStep(session, fieldId, step) - crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { session.deviceId, session.handsetId, fieldId, step }) +-- @param deviceId the target device address +-- @param handsetId the reply-to address +-- @param fieldId the command field's id +-- @param step the command step byte +-- @return frameType, payload +function Params.encodeCommandStep(deviceId, handsetId, fieldId, step) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, fieldId, step } end -- Pseudo-field id: a PARAMETER_WRITE to this id calls supressCriticalErrors() -- in TXModuleEndpoint.cpp (the firmware matches the bare 0x2E literal). local FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E ---- Ask the module to stop reporting its critical error flags (the bits above --- crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD in the ELRS status byte). --- @param session table with deviceId/handsetId -function Fields.sendSuppressCriticalErrors(session) - crsf.push( - crsf.CONST.FRAMETYPE_PARAMETER_WRITE, - { session.deviceId, session.handsetId, FIELD_ID_SUPPRESS_CRITICAL_ERRORS, 0 } - ) +--- Encode the write that asks the module to stop reporting its critical +-- error flags (the bits above crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD in +-- the ELRS status byte). +-- @param deviceId the target device address +-- @param handsetId the reply-to address +-- @return frameType, payload +function Params.encodeSuppressCriticalErrors(deviceId, handsetId) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, FIELD_ID_SUPPRESS_CRITICAL_ERRORS, 0 } end -- ============================================================================ -- Return codec table -- ============================================================================ -return Fields +return Params diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 34bfa14..8a704f1 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -16,7 +16,7 @@ local useLvgl = (lvgl ~= nil) -- ============================================================================ local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local fields = loadScript("/SCRIPTS/ELRS/crsf_fields.lua")(crsf) +local fields = loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf) local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(crsf, fields) local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua index 91d41df..d042282 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -42,15 +42,12 @@ local Protocol = { linkstatTimeout = 100, pingTimeout = 0, - -- Communication state. fieldChunk/fieldData/fieldDataId/expectChunksRemain - -- are the crsf_fields.lua reassembly session keys -- the library manages - -- them; fieldChunk stays readable (the BW UI's popup spinner ticks on it). + -- Communication state. rx is the crsf_params.lua reassembly state -- the + -- codec manages it; rx.chunk stays readable (the BW UI's popup spinner + -- ticks on it). fieldTimeout = 0, - fieldChunk = 0, - fieldData = nil, - fieldDataId = nil, + rx = { chunk = 0, expect = -1 }, loadQueue = {}, - expectChunksRemain = -1, backgroundLoading = false, -- Telemetry transition tracking (for auto-discovery on reconnect) @@ -136,7 +133,7 @@ end function Protocol.reloadAllFields() Protocol.fieldTimeout = 0 - fields.resetChunks(Protocol) + fields.resetChunks(Protocol.rx) Protocol.loadQueue = {} -- Start by loading only field 0 (root folder). -- Its response contains child IDs; only root children are auto-queued. @@ -170,7 +167,7 @@ end function Protocol.reloadCurField(field) Protocol.fieldTimeout = 0 - fields.resetChunks(Protocol) + fields.resetChunks(Protocol.rx) Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id end @@ -257,7 +254,7 @@ function Protocol.handleCommandSave(field) if field.status ~= nil then if field.status < crsf.CONST.CMD_CONFIRMED then field.status = crsf.CONST.CMD_CLICK - fields.sendCommandStep(Protocol, field.id, crsf.CONST.CMD_CLICK) + crsf.push(fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, field.id, crsf.CONST.CMD_CLICK)) Protocol.fieldPopup = field Protocol.fieldPopup.lastStatus = crsf.CONST.CMD_IDLE Protocol.fieldTimeout = getTime() + field.timeout @@ -267,7 +264,9 @@ end function Protocol.commandConfirm() if Protocol.fieldPopup then - fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED) + crsf.push( + fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED) + ) Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout Protocol.fieldPopup.status = crsf.CONST.CMD_CONFIRMED end @@ -276,7 +275,9 @@ end -- Cancel and dismiss: sends CMD_CANCEL and drops the popup immediately. function Protocol.commandCancel() if Protocol.fieldPopup then - fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) + crsf.push( + fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) + ) Protocol.fieldPopup = nil end end @@ -287,7 +288,9 @@ end -- keeps tracking the device's actual command state. function Protocol.commandRequestCancel() if Protocol.fieldPopup then - fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) + crsf.push( + fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) + ) Protocol.fieldTimeout = getTime() + 200 end end @@ -296,7 +299,7 @@ end -- suppress write the module acts on. function Protocol.suppressCriticalErrors() Protocol.elrsFlags = 0 - fields.sendSuppressCriticalErrors(Protocol) + crsf.push(fields.encodeSuppressCriticalErrors(Protocol.deviceId, Protocol.handsetId)) end -- ============================================================================ @@ -324,7 +327,7 @@ end -- frames; the library reassembles the chunks before this decodes one field. function Protocol.parseParameterInfoMessage(data) local expectedId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] - local fieldId, buffer, offset = fields.reassemble(Protocol, data, expectedId) + local fieldId, buffer, offset = fields.reassemble(Protocol.rx, Protocol.deviceId, data, expectedId) if not fieldId then return end @@ -380,7 +383,7 @@ function Protocol.parseElrsInfoMessage(data) return end if status.id ~= Protocol.deviceId then - fields.resetChunks(Protocol) + fields.resetChunks(Protocol.rx) return end @@ -450,7 +453,9 @@ function Protocol.tick() if Protocol.fieldPopup then if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= crsf.CONST.CMD_ASKCONFIRM then - fields.sendCommandStep(Protocol, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY) + crsf.push( + fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY) + ) Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout end elseif time > Protocol.linkstatTimeout then @@ -466,7 +471,9 @@ function Protocol.tick() Protocol.linkstatTimeout = time + 100 elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then if #Protocol.loadQueue > 0 then - fields.sendRead(Protocol, Protocol.loadQueue[#Protocol.loadQueue]) + crsf.push( + fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue]) + ) Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() else Protocol.backgroundLoading = false diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index e78affe..d0cd142 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -522,7 +522,7 @@ function UI.handleEvent(event) elseif not field.disabled and ft <= crsf.CONST.FIELD_TEXT_SELECTION then UI.edit = not UI.edit if not UI.edit then - fields.sendWriteInt(Protocol, field) + crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) Protocol.reloadRelatedFields(field) end end @@ -609,7 +609,7 @@ function UI.drawPopup(event) Protocol.commandCancel() end elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then - if Protocol.fieldChunk == 0 then + if Protocol.rx.chunk == 0 then UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end local result = popupConfirmation( diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 4e8c02d..12c169b 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -701,7 +701,7 @@ function UI.createToggleRow(pg, field) end, set = function(val) field.value = val - fields.sendWriteInt(Protocol, field) + crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) Protocol.reloadRelatedFields(field) end, active = function() @@ -766,7 +766,7 @@ function UI.createChoiceRow(pg, field) end, set = function(val) field.value = val - 1 - fields.sendWriteInt(Protocol, field) + crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) Protocol.reloadRelatedFields(field) end, active = function() @@ -805,7 +805,7 @@ function UI.createNumberRow(pg, field) end, edited = function(val) field.value = val - fields.sendWriteInt(Protocol, field) + crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) Protocol.reloadRelatedFields(field) end, display = function(val) @@ -901,7 +901,7 @@ function UI.createStringRow(pg, field) length = math.min(math.max(field.maxlen or 32, 32), 128), set = function(val) field.value = val - fields.sendWriteString(Protocol, field) + crsf.push(fields.encodeWriteString(Protocol.deviceId, Protocol.handsetId, field)) Protocol.reloadRelatedFields(field) end, active = function() diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 751f632..527665c 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -154,15 +154,12 @@ Protocol = { state = 0, -- STATE_INIT statusText = "Initializing...", - -- crsf_fields.lua session: addressing bytes plus the reassembly keys the - -- library manages (fieldChunk doubles as the next chunk index sendRead - -- carries, giving multi-chunk entries their follow-up reads). + -- CRSF addressing plus the crsf_params.lua reassembly state the codec + -- manages (rx.chunk doubles as the next chunk index encodeRead carries, + -- giving multi-chunk entries their follow-up reads). deviceId = crsf.CONST.ADDRESS_TX, handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, - fieldChunk = 0, - fieldData = nil, - fieldDataId = nil, - expectChunksRemain = -1, + rx = { chunk = 0, expect = -1 }, -- Discovery loadQueue = {}, @@ -225,7 +222,7 @@ function Protocol.armFolderRead(delay) Protocol.folderReadAttempts = 0 -- A fresh read must not inherit chunk state from an interrupted one (e.g. -- resume from suspension mid-entry). - fields.resetChunks(Protocol) + fields.resetChunks(Protocol.rx) end -- ============================================================================ @@ -235,9 +232,9 @@ end function Protocol.onSettingsEntry(data) -- Passive fan-out mode: accept any field from our device (data[3] as the -- expected id) -- sibling widget instances depend on seeing responses they - -- did not request. The library gates on Protocol.deviceId and keeps + -- did not request. The codec gates on the device address and keeps -- cross-field continuations out of an in-flight reassembly buffer. - local fieldId, buffer, offset = fields.reassemble(Protocol, data, data[3]) + local fieldId, buffer, offset = fields.reassemble(Protocol.rx, Protocol.deviceId, data, data[3]) if not fieldId then return end @@ -362,7 +359,7 @@ function Protocol.tick() then if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then local fieldId = Protocol.loadQueue[#Protocol.loadQueue] - fields.sendRead(Protocol, fieldId) + crsf.push(fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, fieldId)) Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() end elseif st == Protocol.STATE_READY then @@ -370,7 +367,7 @@ function Protocol.tick() if Protocol.folderReadAttempts < Protocol.FOLDER_READ_RETRIES then Protocol.folderReadAttempts = Protocol.folderReadAttempts + 1 Protocol.folderReadPending = now + Protocol.fieldResponseTimeout() - fields.sendRead(Protocol, VTX.ids.folder) + crsf.push(fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, VTX.ids.folder)) else Protocol.folderReadPending = nil end @@ -380,7 +377,7 @@ function Protocol.tick() if now - Protocol.lastWriteTime >= 5 then -- 50ms local entry = Protocol.writeQueue[Protocol.writeIdx] print(table.concat({ "VTXAdmin: writing field=", entry.id, " val=", entry.value })) - fields.sendWriteInt(Protocol, entry) + crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, entry)) Protocol.lastWriteTime = now Protocol.writeIdx = Protocol.writeIdx + 1 end diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index 29bb154..9d03779 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -16,14 +16,14 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end - if not _crsfFieldsSingleton then - local getFields = loadScript("/SCRIPTS/ELRS/crsf_fields.lua") + if not _crsfParamsSingleton then + local getParams = loadScript("/SCRIPTS/ELRS/crsf_params.lua") ---@diagnostic disable-next-line: need-check-nil - _crsfFieldsSingleton = getFields(_crsfSingleton) + _crsfParamsSingleton = getParams(_crsfSingleton) end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton, _crsfFieldsSingleton) + return loadable(zone, options, _crsfSingleton, _crsfParamsSingleton) end local function refresh(widget, event, touchState) From 6af955ada64188a5d4b774e1cf16d5735efcb3e9 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 17:43:20 +0300 Subject: [PATCH 165/218] Make the CRSF parameter session an explicit class crsf_session.lua is a stateful CRSF parameter client (CRSFSession.new, multi-instance): it owns the field store, the load queue, the paced write queue, the command state machine, and optionally device discovery, link status and ELRS 1.x detection. The overloaded fieldTimeout becomes named per-purpose deadlines, and tick() documents the send ladder it always implied: at most one parameter frame per tick, command keep-alive > write drain > link-status > reads. protocol.lua dissolves: the session absorbs its machinery, main.lua's App keeps the policy residue (device switching, folder-ready edges, the synthetic "Other Devices" row types), and both UIs talk to the session surface -- writes go through writeField, which encodes by field type and re-reads the related fields itself, so the codec no longer appears in any UI. Two deliberate hardenings over the old engine: the load-queue head pops only when the decoded entry answers it, and CMD_IDLE dismisses the popup only for the active command. --- README.md | 2 +- docs/development.md | 4 +- src/SCRIPTS/ELRS/crsf_session.lua | 722 ++++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 82 ++- src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua | 484 --------------- src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 89 ++- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 100 ++- 7 files changed, 862 insertions(+), 621 deletions(-) create mode 100644 src/SCRIPTS/ELRS/crsf_session.lua delete mode 100644 src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua diff --git a/README.md b/README.md index 0c8f4d7..cb62fcf 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,12 @@ SCRIPTS/ ELRS/ crsf.lua -- shared CRSF protocol library crsf_params.lua -- parameter codec (tool, VTX Admin) + crsf_session.lua -- stateful parameter client (tool, VTX Admin) crsf_elrsinfo.lua -- TX module info state (telemetry widget) shim.lua -- BW compatibility shim TOOLS/ ExpressLRS/ main.lua -- entry point - protocol.lua -- device discovery and field policy navigation.lua -- folder navigation ui/ lvgl.lua -- color LCD UI (LVGL) diff --git a/docs/development.md b/docs/development.md index 56b8513..a74dc61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,8 +34,7 @@ Run `make help` to list all targets. The Makefile groups them into three categor | Module | Purpose | |--------|---------| -| `main.lua` | Entry point and run-loop orchestrator | -| `protocol.lua` | Device discovery, load-queue and command-popup policy over the shared field engine | +| `main.lua` | Entry point, run-loop orchestrator, and the App policy layer (device switching, folder-ready edges, the synthetic "Other Devices" row types) over a `crsf_session.lua` instance | | `navigation.lua` | Folder and device navigation stack | | `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | | `ui/lcd.lua` | BW LCD interface (text cursor, popups) | @@ -46,6 +45,7 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too |--------|---------| | `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | +| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua new file mode 100644 index 0000000..76c36d2 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -0,0 +1,722 @@ +--------------------------------------------------------------------------- +-- CRSF Parameter Session -- +-- -- +-- A stateful CRSF parameter client: owns the field store, the load -- +-- queue and its retry scheduler, the write queue and its pacing, the -- +-- command state machine, and optionally device discovery, link status -- +-- and ELRS 1.x detection. Mechanism lives here; policy -- what to load, -- +-- when to write, how to render -- stays with the caller, which reacts -- +-- through the store queries and the onFieldUpdate / onDeviceUpdate -- +-- callbacks. -- +-- -- +-- Multiple instances are real: the config tool and every VTX Admin -- +-- widget instance each own one. Methods live on the shared metatable, -- +-- so an instance costs one table plus its opts callbacks. -- +-- -- +-- Two receive models converge on _onFrame: a standalone tool drains -- +-- the destructive pop queue itself (drain()), widgets register on the -- +-- crsf.lua fan-out once (attachBus()) and crsf:poll() feeds them. -- +-- -- +-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf,params). -- +-- Returns the CRSFSession class; construct with CRSFSession.new(opts). -- +--------------------------------------------------------------------------- + +local crsf, params = ... + +-- Scheduler constants (ticks, 10 ms each) +local STATUS_PERIOD = 100 -- link-status cadence (1 s) +local PING_PERIOD = 100 -- discovery ping cadence while no device answered +local WRITE_SPACING = 5 -- minimum gap between parameter writes (50 ms) +local WRITE_SETTLE = 20 -- post-write quiet time before the next read +local CANCEL_GRACE = 200 -- wait for CMD_IDLE after a requested cancel (2 s) + +local CRSFSession = {} +CRSFSession.__index = CRSFSession + +--- Create a session. +-- @param opts table with: +-- deviceId target device address (default ADDRESS_TX) +-- handsetId reply-to address (default ADDRESS_HANDSET_ELRS) +-- responseTimeout fixed read-retry deadline in ticks; when omitted it is +-- derived per device: 50 for the local ELRS TX, 500 for +-- remote devices relayed over the air link +-- acceptUnsolicited true accepts any field from deviceId (passive fan-out: +-- sibling instances see every response); default strict, +-- accepting only the answer the session is waiting for +-- discovery maintain .devices from DEVICE_INFO + ping cadence +-- trackStatus keep .status current from ELRS_STATUS (1 Hz cadence) +-- detectV1 watch inbound PARAMETER_WRITE for the ELRS 1.x +-- signature, latching .v1Detected +-- preload once the root load completes, queue every unloaded +-- subfolder child for background loading +-- onFieldUpdate function(field) called after each decoded entry +-- onDeviceUpdate function(device, isNew) called after each DEVICE_INFO +function CRSFSession.new(opts) + opts = opts or {} + return setmetatable({ + -- Public facts + deviceId = opts.deviceId or crsf.CONST.ADDRESS_TX, + handsetId = opts.handsetId or crsf.CONST.ADDRESS_HANDSET_ELRS, + deviceName = nil, + isElrsTx = nil, + fieldsCount = 0, + devices = {}, + command = nil, -- the command field driving the active popup + -- Link status; table identity is stable, only keys change + status = { flags = 0, warning = "" }, + -- Read-and-clear flags for the app + fieldHiddenChanged = nil, + v1Detected = nil, + -- Reassembly state (crsf_params.lua manages it; rx.chunk is readable) + rx = { chunk = 0, expect = -1 }, + + -- Options + _acceptUnsolicited = opts.acceptUnsolicited, + _discovery = opts.discovery, + _trackStatus = opts.trackStatus, + _detectV1 = opts.detectV1, + _preload = opts.preload, + _respTimeout = opts.responseTimeout, + _onFieldUpdate = opts.onFieldUpdate, + _onDeviceUpdate = opts.onDeviceUpdate, + + -- Field store: id -> field table, get-or-create on first entry so a + -- field's table identity is stable across reloads (UI closures hold it) + _fields = {}, + + -- Load queue (LIFO) and the five scheduler deadlines + _loadQueue = {}, + _nextReadAt = 0, + _nextQueryAt = 0, + _nextStatusAt = 0, + _nextPingAt = 0, + _lastWriteAt = 0, + + -- Write queue: encoded frames between head and tail, paced by tick() + _writeQueue = {}, + _writeHead = 1, + _writeTail = 0, + + -- Bounded best-effort refresh slot (refreshField) + _refreshId = nil, + _refreshAt = 0, + _refreshLeft = 0, + _refreshAttempts = 0, + + _hadTelemetry = false, + _preloadArmed = nil, + _preloading = nil, + }, CRSFSession) +end + +-- Read-retry deadline for PARAMETER_READ: a fixed opts.responseTimeout wins, +-- otherwise 0.5 s for the local TX module, 5 s for remote devices relayed +-- over the air link. +function CRSFSession:_responseTimeout() + if self._respTimeout then + return self._respTimeout + end + return self.isElrsTx and 50 or 500 +end + +-- ============================================================================ +-- Lifecycle +-- ============================================================================ + +--- Point the session at a device (a .devices entry) and reload its fields. +-- @return true when the device changed, false when nothing needed doing +function CRSFSession:setDevice(device) + if not device then + return false + end + if self.deviceId == device.id and self.fieldsCount == device.fieldCount then + return false + end + + self.deviceId = device.id + self.deviceName = device.name + self.fieldsCount = device.fieldCount + self.isElrsTx = device.isElrs and device.id == crsf.CONST.ADDRESS_TX or nil + self.handsetId = self.isElrsTx and crsf.CONST.ADDRESS_HANDSET_ELRS or crsf.CONST.ADDRESS_HANDSET + local st = self.status + st.flags = 0 + st.connected = nil + st.modelMismatch = nil + st.criticalError = nil + + self:reloadAll() + return true +end + +--- Drain the destructive pop queue into the session (pull model, for a +-- standalone tool that owns the queue). Widgets use attachBus() instead. +function CRSFSession:drain() + local command, data + repeat + command, data = crsf.pop() + if command then + self:_onFrame(command, data) + end + until command == nil +end + +--- Register the session's frame handlers on the crsf.lua fan-out (push +-- model, for widgets). Call once; crsf:poll() then feeds the session. +function CRSFSession:attachBus() + crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, function(data) + self:_onEntry(data) + end) + if self._discovery then + crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, function(data) + self:_onDeviceInfo(data) + end) + end + if self._trackStatus then + crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, function(data) + self:_onStatus(data) + end) + end + if self._detectV1 then + crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, function(data) + self:_onWrite(data) + end) + end +end + +-- ============================================================================ +-- Frame handlers +-- ============================================================================ + +function CRSFSession:_onFrame(command, data) + if command == crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + self:_onEntry(data) + elseif command == crsf.CONST.FRAMETYPE_DEVICE_INFO then + if self._discovery then + self:_onDeviceInfo(data) + end + elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then + if self._trackStatus then + self:_onStatus(data) + end + elseif command == crsf.CONST.FRAMETYPE_PARAMETER_WRITE then + if self._detectV1 then + self:_onWrite(data) + end + end +end + +function CRSFSession:_onDeviceInfo(data) + local info = crsf:decodeDeviceInfo(data) + if not info then + return + end + local device = self:getDevice(info.id) + local isNew = device == nil + if isNew then + device = { id = info.id } + self.devices[#self.devices + 1] = device + end + device.name = info.name + device.fieldCount = info.fieldCount + device.isElrs = info.isElrs + if self._onDeviceUpdate then + self._onDeviceUpdate(device, isNew) + end +end + +function CRSFSession:_onStatus(data) + local status = crsf:decodeElrsStatus(data) + if not status then + return + end + if status.id ~= self.deviceId then + -- A foreign device's status while we buffer chunks means our entry + -- stream was interrupted: abandon it + params.resetChunks(self.rx) + return + end + local st = self.status + st.lostPackets = status.lostPackets + st.receivedPackets = status.receivedPackets + st.flags = status.flags + st.connected = status.connected + st.modelMismatch = status.modelMismatch + st.criticalError = status.criticalError + st.warning = status.warning +end + +function CRSFSession:_onWrite(data) + if crsf:isElrsV1Frame(data) then + self.v1Detected = true + end +end + +function CRSFSession:_onEntry(data) + local expectedId + if self._acceptUnsolicited then + expectedId = data[3] + else + expectedId = (self.command and self.command.id) or self._loadQueue[#self._loadQueue] or self._refreshId + end + local fieldId, buffer, offset = params.reassemble(self.rx, self.deviceId, data, expectedId) + if not fieldId then + return + end + local now = getTime() + if not buffer then + -- Chunk consumed: hurry the follow-up read, which carries the updated + -- chunk index. A refresh slot answering mid-entry burns no attempt. + if self._loadQueue[#self._loadQueue] == fieldId then + self._nextReadAt = 0 + elseif self._refreshId == fieldId then + self._refreshAt = now + self._refreshLeft = self._refreshAttempts + elseif self.command then + self._nextQueryAt = now + (self.command.timeout or 100) + end + return + end + + -- Entry complete: it settles the queue head only when it answers it -- a + -- command status elicited while loads are pending must not pop an + -- unrelated field + if self._loadQueue[#self._loadQueue] == fieldId then + self._loadQueue[#self._loadQueue] = nil + end + if self._refreshId == fieldId then + self._refreshId = nil + end + + local field = self._fields[fieldId] + if not field then + field = {} + self._fields[fieldId] = field + end + + -- Hidden-bit changes rebuild the UI's cached list of visible fields, so + -- track it across the decode. + local wasHidden = field.hidden + -- Passing the old name makes the decoder skip its read and reuse that + -- string, which is only safe while no reload has flagged the name as + -- possibly changed + local cachedName = (not field.nameStale and not field.reloading) and field.name or nil + if params.decodeEntry(field, fieldId, buffer, offset, cachedName) then + field.nameStale = nil + field.reloading = nil + if field.hidden ~= wasHidden then + self.fieldHiddenChanged = true + end + + if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE and self.command == field then + -- The active command just finished (or was cancelled): re-read its + -- same-level fields so the current page reflects any values the + -- command changed, and dismiss the popup. The guard limits both to + -- the active command -- routine loads of idle command fields while + -- browsing must not trigger either. + self:_reloadRelated(field) + self.command = nil + end + + -- Auto-queue children for the root folder and during preloading + if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or self._preloading) then + for i = #field.children, 1, -1 do + self._loadQueue[#self._loadQueue + 1] = field.children[i] + end + end + + if self._onFieldUpdate then + self._onFieldUpdate(field) + end + end + + if self._loadQueue[1] then + self._nextReadAt = 0 + else + if self.command then + self._nextQueryAt = now + (self.command.timeout or 100) + end + if self._preloadArmed and self:isFolderLoaded(nil) then + self._preloadArmed = nil + self:preloadAll() + end + end +end + +-- ============================================================================ +-- Store queries +-- ============================================================================ + +--- The field table for an id, or nil while it is unknown. +function CRSFSession:field(id) + return self._fields[id] +end + +--- The device table for an address, or nil (opts.discovery fills .devices). +function CRSFSession:getDevice(id) + for _, device in ipairs(self.devices) do + if device.id == id then + return device + end + end +end + +--- Loaded children of a folder, in wire order. folderId nil means root. +function CRSFSession:fieldsInFolder(folderId) + local folder = self._fields[folderId or 0] + if not folder or not folder.children then + return {} + end + local result = {} + for _, childId in ipairs(folder.children) do + local child = self._fields[childId] + if child and child.name then + result[#result + 1] = child + end + end + return result +end + +--- True when every child of a folder is loaded. folderId nil means root. +function CRSFSession:isFolderLoaded(folderId) + local folder = self._fields[folderId or 0] + if not folder or not folder.children then + return false + end + for _, childId in ipairs(folder.children) do + local child = self._fields[childId] + if not child or not child.name or child.nameStale then + return false + end + end + return true +end + +--- Load progress for a folder's children as (loaded, total), or nil while +-- the folder or its children list is unknown. folderId nil means root. +function CRSFSession:folderLoadProgress(folderId) + local folder = self._fields[folderId or 0] + if not folder or not folder.children then + return nil + end + local total = #folder.children + local loaded = 0 + for _, childId in ipairs(folder.children) do + local child = self._fields[childId] + if child and child.name and not child.reloading then + loaded = loaded + 1 + end + end + return loaded, total +end + +--- True while reads are queued. +function CRSFSession:isLoading() + return self._loadQueue[1] ~= nil +end + +--- True while a multi-chunk entry is mid-reassembly. +function CRSFSession:isReceivingChunks() + return self.rx.chunk > 0 +end + +--- True while background preloading (opts.preload) is filling the store. +function CRSFSession:isPreloading() + return self._preloading ~= nil +end + +--- True while writes are waiting in the paced write queue. +function CRSFSession:isWriting() + return self._writeHead <= self._writeTail +end + +-- ============================================================================ +-- Loading +-- ============================================================================ + +--- Forget every field and reload from the root folder. Its response carries +-- the child ids, which auto-queue; subfolder children load on demand via +-- loadFolder() or in the background via opts.preload. +function CRSFSession:reloadAll() + self._fields = {} + self._loadQueue = { 0 } + self._nextReadAt = 0 + self._preloadArmed = self._preload + self._preloading = nil + params.resetChunks(self.rx) +end + +--- Re-read one field now. +function CRSFSession:reloadField(field) + self._nextReadAt = 0 + params.resetChunks(self.rx) + self._loadQueue[#self._loadQueue + 1] = field.id +end + +--- Queue a folder's unloaded children. +function CRSFSession:loadFolder(folderId) + local folder = self._fields[folderId] + if not folder or not folder.children then + return + end + for i = #folder.children, 1, -1 do + local childId = folder.children[i] + local child = self._fields[childId] + if not (child and child.name) then + self._loadQueue[#self._loadQueue + 1] = childId + end + end + if self._loadQueue[1] then + self._nextReadAt = 0 + end +end + +--- Queue every unloaded subfolder child for background loading. +function CRSFSession:preloadAll() + self._preloading = true + for id = 1, self.fieldsCount do + local field = self._fields[id] + if field and field.type == crsf.CONST.FIELD_FOLDER and field.children then + for j = #field.children, 1, -1 do + local childId = field.children[j] + local child = self._fields[childId] + if not (child and child.name) then + self._loadQueue[#self._loadQueue + 1] = childId + end + end + end + end + if self._loadQueue[1] then + self._nextReadAt = 0 + end +end + +--- Arm the bounded best-effort refresh slot: read fieldId `delay` ticks from +-- now, retrying at most `attempts` times if unanswered. Event-driven, never +-- periodic -- use it when something else can have changed the device (our +-- own writes, resume from suspension), not as a poll. +function CRSFSession:refreshField(fieldId, delay, attempts) + self._refreshId = fieldId + self._refreshAt = getTime() + (delay or 0) + self._refreshAttempts = attempts or 3 + self._refreshLeft = self._refreshAttempts + -- A fresh read must not inherit chunk state from an interrupted one + params.resetChunks(self.rx) +end + +-- ============================================================================ +-- Writing +-- ============================================================================ + +-- Re-read what a value change can have altered: the parent folder (its name +-- may embed values) and every non-folder sibling (CRSF parameters on one +-- level are routinely interdependent -- option lists shrink, fields hide). +function CRSFSession:_reloadRelated(field) + if field.parent and self._fields[field.parent] then + self._fields[field.parent].nameStale = true + self._loadQueue[#self._loadQueue + 1] = field.parent + end + + for fieldId = self.fieldsCount, 1, -1 do + local sibling = self._fields[fieldId] + if sibling and fieldId ~= field.id and sibling.parent == field.parent then + local siblingType = sibling.type or 99 + if siblingType < crsf.CONST.FIELD_FOLDER or siblingType == crsf.CONST.FIELD_INFO then + sibling.dirty = true + sibling.reloading = true + self._loadQueue[#self._loadQueue + 1] = fieldId + end + end + end + + field.dirty = true + field.reloading = true + self._loadQueue[#self._loadQueue + 1] = field.id + self:_afterWrite() +end + +-- Post-write settle: give the device WRITE_SETTLE to apply the change +-- before the re-reads go out, and keep the next link-status request from +-- landing inside that window. +function CRSFSession:_afterWrite() + local now = getTime() + self._nextReadAt = now + WRITE_SETTLE + local statusAt = now + WRITE_SETTLE + STATUS_PERIOD + if self._nextStatusAt < statusAt then + self._nextStatusAt = statusAt + end +end + +--- Write a field's current value to the device: encodes by field type, +-- sends immediately when the wire is idle (every push replaces one +-- RC-channels frame, so bursts are paced by tick()). In strict mode the +-- write also re-reads its related fields; a passive fan-out session owns +-- its read-back policy (refreshField). +function CRSFSession:writeField(field) + local frameType, payload + if field.type == crsf.CONST.FIELD_STRING then + frameType, payload = params.encodeWriteString(self.deviceId, self.handsetId, field) + else + frameType, payload = params.encodeWriteInt(self.deviceId, self.handsetId, field) + end + + local now = getTime() + if self._writeHead > self._writeTail and now - self._lastWriteAt >= WRITE_SPACING then + crsf.push(frameType, payload) + self._lastWriteAt = now + else + self._writeTail = self._writeTail + 1 + self._writeQueue[self._writeTail] = { frameType, payload } + end + + if not self._acceptUnsolicited then + self:_reloadRelated(field) + end +end + +-- ============================================================================ +-- Commands +-- ============================================================================ + +--- Click a command field. When the device accepts, session.command holds the +-- field until it reports CMD_IDLE (or the command is cancelled); the UI +-- renders its popup from session.command.status/info. +function CRSFSession:execCommand(field) + self:reloadField(field) + if field.status ~= nil and field.status < crsf.CONST.CMD_CONFIRMED then + field.status = crsf.CONST.CMD_CLICK + crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, field.id, crsf.CONST.CMD_CLICK)) + self.command = field + self._nextQueryAt = getTime() + (field.timeout or 100) + end +end + +--- Answer the device's CMD_ASKCONFIRM. +function CRSFSession:confirmCommand() + if self.command then + crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CONFIRMED)) + self._nextQueryAt = getTime() + (self.command.timeout or 100) + self.command.status = crsf.CONST.CMD_CONFIRMED + end +end + +--- Cancel and dismiss: sends CMD_CANCEL and drops the popup immediately. +function CRSFSession:cancelCommand() + if self.command then + crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CANCEL)) + self.command = nil + end +end + +--- Cancel but keep the popup: sends CMD_CANCEL and waits CANCEL_GRACE for +-- the device to report CMD_IDLE, which dismisses the popup through the +-- entry decode. Used while no dialog is on screen yet (e.g. right after +-- CMD_CLICK), so the UI keeps tracking the device's actual command state. +function CRSFSession:requestCancelCommand() + if self.command then + crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CANCEL)) + self._nextQueryAt = getTime() + CANCEL_GRACE + end +end + +-- ============================================================================ +-- Status +-- ============================================================================ + +--- Clear the ELRS critical-error banner: optimistic local clear plus the +-- suppress write the module acts on. +function CRSFSession:suppressCriticalErrors() + self.status.flags = 0 + crsf.push(params.encodeSuppressCriticalErrors(self.deviceId, self.handsetId)) +end + +-- ============================================================================ +-- Scheduler +-- ============================================================================ + +--- Send what is due. At most one parameter frame per call, strict priority: +-- command keep-alive > write drain > link-status > reads (refresh slot, +-- then load-queue head). While a command runs it owns the wire -- reads +-- starve by design, and the field data rides its CMD_QUERY answers. +-- Discovery pings sit outside that chain: they cost no parameter traffic. +function CRSFSession:tick() + local now = getTime() + + if self._discovery then + -- Ping on telemetry transition (the answering device may have changed) + local connected = self.status.connected + if connected and not self._hadTelemetry then + crsf:pingDevices() + end + self._hadTelemetry = connected + -- Periodic ping for initial device discovery + if #self.devices == 0 and now > self._nextPingAt then + crsf:pingDevices() + self._nextPingAt = now + PING_PERIOD + end + end + + if self.command then + if now > self._nextQueryAt and self.command.status ~= crsf.CONST.CMD_ASKCONFIRM then + crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_QUERY)) + self._nextQueryAt = now + (self.command.timeout or 100) + end + return + end + + if self._writeHead <= self._writeTail then + if now - self._lastWriteAt >= WRITE_SPACING then + local write = self._writeQueue[self._writeHead] + self._writeQueue[self._writeHead] = nil + self._writeHead = self._writeHead + 1 + if self._writeHead > self._writeTail then + self._writeHead = 1 + self._writeTail = 0 + end + crsf.push(write[1], write[2]) + self._lastWriteAt = now + end + return + end + + if self._trackStatus and now > self._nextStatusAt then + if self.isElrsTx then + -- isElrsTx guarantees deviceId/handsetId are ADDRESS_TX and + -- ADDRESS_HANDSET_ELRS here (see setDevice), the addressing + -- requestElrsStatus() hardcodes. + crsf:requestElrsStatus() + else + self.status.receivedPackets = nil + self.status.lostPackets = nil + end + self._nextStatusAt = now + STATUS_PERIOD + return + end + + if self._refreshId then + if now >= self._refreshAt then + if self._refreshLeft > 0 then + self._refreshLeft = self._refreshLeft - 1 + self._refreshAt = now + self:_responseTimeout() + crsf.push(params.encodeRead(self.rx, self.deviceId, self.handsetId, self._refreshId)) + else + self._refreshId = nil + end + end + return + end + + if now > self._nextReadAt then + if self._loadQueue[1] then + crsf.push(params.encodeRead(self.rx, self.deviceId, self.handsetId, self._loadQueue[#self._loadQueue])) + self._nextReadAt = now + self:_responseTimeout() + else + self._preloading = nil + end + end +end + +-- ============================================================================ +-- Return class +-- ============================================================================ + +return CRSFSession diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 8a704f1..cd86415 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -16,20 +16,31 @@ local useLvgl = (lvgl ~= nil) -- ============================================================================ local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local fields = loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf) -local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(crsf, fields) +local params = loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf) +local CRSFSession = loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf, params) local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() -- ============================================================================ --- App Module: Pure business logic (zero UI references) +-- App Module: business logic between the session and the UI -- ============================================================================ local App = { + -- Tool-internal pseudo field types for the synthetic device rows in the + -- "Other Devices" list. Values sit above the wire range: the type byte is + -- masked with 0x7f at parse, so a real field type can never exceed 127 -- + -- unlike 15/16, which the previous numbering used and which shadow + -- CRSF_VTX (0x0F) on the wire. + DEVICE = 128, + DEVICE_FOLDER = 129, + crsfModuleChecked = false, crsfModuleFound = false, shouldExit = false, } +local UI +local session + function App.checkCrsfModule() if App.crsfModuleChecked then return App.crsfModuleFound @@ -41,7 +52,7 @@ end -- Returns true if device was set, false if no change needed. function App.loadDevice(device) - if Protocol.setDevice(device) then + if session:setDevice(device) then Navigation.reset() return true end @@ -50,12 +61,12 @@ end -- Returns true if device was switched. function App.switchDevice(deviceId, viewState) - local device = Protocol.getDevice(deviceId) + local device = session:getDevice(deviceId) if not device then return false end - local prevDeviceId = Protocol.deviceId - if Protocol.setDevice(device) then + local prevDeviceId = session.deviceId + if session:setDevice(device) then Navigation.openDevice(device.name, prevDeviceId, viewState) return true end @@ -65,7 +76,7 @@ end -- Navigate into folder. function App.enterFolder(folderId, folderName, viewState) Navigation.openFolder(folderId, folderName, viewState) - Protocol.loadFolderChildren(folderId) + session:loadFolder(folderId) end -- Returns navigation entry (or nil). @@ -75,23 +86,39 @@ end -- Reload at root: switch back to TX device or reload fields + ping. function App.reloadAtRoot() - if Protocol.deviceId ~= crsf.CONST.ADDRESS_TX then - local txDevice = Protocol.getDevice(crsf.CONST.ADDRESS_TX) + if session.deviceId ~= crsf.CONST.ADDRESS_TX then + local txDevice = session:getDevice(crsf.CONST.ADDRESS_TX) if txDevice then App.loadDevice(txDevice) end else - Protocol.allocateFields() - Protocol.reloadAllFields() + session:reloadAll() end crsf:pingDevices() end -- ============================================================================ --- UI loading (deferred to init) +-- Session: the tool talks to one device at a time, tracking it fully -- ============================================================================ -local UI +session = CRSFSession.new({ + discovery = true, + trackStatus = true, + detectV1 = true, + preload = true, + onDeviceUpdate = function(device, isNew) + if device.id == session.deviceId and App.loadDevice(device) then + UI.onDeviceLoaded() + end + if isNew then + UI.onNewDevice() + end + end, +}) + +-- ============================================================================ +-- UI loading (deferred to init) +-- ============================================================================ -- Module table, forward-declared so init() can drop itself once it has run. local M = {} @@ -100,9 +127,8 @@ local function init() local deps = { App = App, Navigation = Navigation, - Protocol = Protocol, + session = session, crsf = crsf, - fields = fields, VERSION = VERSION, } if useLvgl then @@ -141,36 +167,24 @@ local function run(event, touchState) return 0 end - local targetDevice, anyNewDevice = Protocol.poll() - Protocol.tick() + session:drain() + session:tick() - if Protocol.elrsV1Detected then + if session.v1Detected then UI.handleUnsupported() return 0 end - if targetDevice then - if App.loadDevice(targetDevice) then - UI.onDeviceLoaded() - end - end - if anyNewDevice then - UI.onNewDevice() - end - local currentFolder = Navigation.getCurrent() - local folderReady = Protocol.isFolderLoaded(currentFolder) + local folderReady = session:isFolderLoaded(currentFolder) if folderReady and not UI.folderWasReady then collectgarbage("collect") UI.invalidate() - if currentFolder == nil and not Protocol.backgroundLoading then - Protocol.startBackgroundLoad() - end end UI.folderWasReady = folderReady - if Protocol.fieldHiddenChanged then - Protocol.fieldHiddenChanged = nil + if session.fieldHiddenChanged then + session.fieldHiddenChanged = nil UI.visibleFields = nil end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua deleted file mode 100644 index d042282..0000000 --- a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua +++ /dev/null @@ -1,484 +0,0 @@ ----- ######################################################################### ----- # Protocol Module: CRSF constants, parsing, and field operations # ----- # Shared between BW and LVGL UI implementations # ----- ######################################################################### - -local crsf, fields = ... - -local Protocol = { - -- Tool-internal pseudo field types for the synthetic device rows in the - -- "Other Devices" list. Values sit above the wire range: the type byte is - -- masked with 0x7f at parse, so a real field type can never exceed 127 -- - -- unlike 15/16, which the previous numbering used and which shadow - -- CRSF_VTX (0x0F) on the wire. - DEVICE = 128, - DEVICE_FOLDER = 129, - - -- Device identity (used in every CRSF frame) - deviceId = crsf.CONST.ADDRESS_TX, - handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, - deviceName = nil, - deviceIsELRS_TX = nil, - - -- Fields collection - fields = {}, - fieldsCount = 0, - fieldPopup = nil, - - -- Devices collection - devices = {}, - - -- Status/flags (parsed from ELRS info messages) - elrsFlags = 0, - elrsFlagsInfo = "", - elrsV1Detected = false, - receivedPackets = nil, - lostPackets = nil, - connected = nil, - modelMismatch = nil, - criticalError = nil, - - -- Protocol timing - linkstatTimeout = 100, - pingTimeout = 0, - - -- Communication state. rx is the crsf_params.lua reassembly state -- the - -- codec manages it; rx.chunk stays readable (the BW UI's popup spinner - -- ticks on it). - fieldTimeout = 0, - rx = { chunk = 0, expect = -1 }, - loadQueue = {}, - backgroundLoading = false, - - -- Telemetry transition tracking (for auto-discovery on reconnect) - hadTelemetry = false, -} - --- Response timeout for PARAMETER_READ: --- 0.5s for local TX module, 5s for remote devices relayed over air link. -function Protocol.fieldResponseTimeout() - return Protocol.deviceIsELRS_TX and 50 or 500 -end - --- Set active device and prepare fields --- Returns true if device changed, false if no change needed -function Protocol.setDevice(device) - if not device then - return false - end - if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then - return false - end - - Protocol.deviceId = device.id - Protocol.elrsFlags = 0 - Protocol.connected = nil - Protocol.modelMismatch = nil - Protocol.criticalError = nil - Protocol.deviceName = device.name - Protocol.fieldsCount = device.fieldCount - Protocol.deviceIsELRS_TX = device.isElrs and device.id == crsf.CONST.ADDRESS_TX or nil - Protocol.handsetId = Protocol.deviceIsELRS_TX and crsf.CONST.ADDRESS_HANDSET_ELRS or crsf.CONST.ADDRESS_HANDSET - - Protocol.allocateFields() - Protocol.reloadAllFields() - return true -end - --- ============================================================================ --- Field management functions --- ============================================================================ - -function Protocol.allocateFields() - Protocol.fields = {} - Protocol.fields[0] = {} -- root folder (field 0) - for i = 1, Protocol.fieldsCount do - Protocol.fields[i] = {} - end -end - --- Check if all children of a folder have been loaded (have names). --- folderId: the folder's field ID, or nil for root (uses field 0). -function Protocol.isFolderLoaded(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return false - end - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if not child or not child.name or child.nameStale then - return false - end - end - return true -end - --- Return load progress for a folder's children as (loaded, total). --- Returns nil if the folder or its children list is unknown yet. -function Protocol.getFolderLoadProgress(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return nil - end - local total = #folder.children - local loaded = 0 - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name and not child.reloading then - loaded = loaded + 1 - end - end - return loaded, total -end - -function Protocol.reloadAllFields() - Protocol.fieldTimeout = 0 - fields.resetChunks(Protocol.rx) - Protocol.loadQueue = {} - -- Start by loading only field 0 (root folder). - -- Its response contains child IDs; only root children are auto-queued. - -- Subfolder children are loaded on-demand via loadFolderChildren(). - Protocol.loadQueue[1] = 0 -end - --- Parameterized: takes folderId instead of accessing Navigation -function Protocol.getFieldsInFolder(folderId) - local folder = Protocol.fields[folderId or 0] - if not folder or not folder.children then - return {} - end - local result = {} - for _, childId in ipairs(folder.children) do - local child = Protocol.fields[childId] - if child and child.name then - result[#result + 1] = child - end - end - return result -end - -function Protocol.getDevice(id) - for _, device in ipairs(Protocol.devices) do - if device.id == id then - return device - end - end -end - -function Protocol.reloadCurField(field) - Protocol.fieldTimeout = 0 - fields.resetChunks(Protocol.rx) - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id -end - --- Queue unloaded children of a folder for on-demand loading. -function Protocol.loadFolderChildren(folderId) - local folder = Protocol.fields[folderId] - if not folder or not folder.children then - return - end - for i = #folder.children, 1, -1 do - local childId = folder.children[i] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - --- Queue all unloaded subfolder children for background preloading. -function Protocol.startBackgroundLoad() - Protocol.backgroundLoading = true - for i = 1, #Protocol.fields do - local field = Protocol.fields[i] - if field.type == crsf.CONST.FIELD_FOLDER and field.children then - for j = #field.children, 1, -1 do - local childId = field.children[j] - local child = Protocol.fields[childId] - if child and not child.name then - Protocol.loadQueue[#Protocol.loadQueue + 1] = childId - end - end - end - end - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - end -end - --- ============================================================================ --- Related fields reload (for value changes) --- ============================================================================ - -function Protocol.reloadParentFolder(field) - if field.parent and Protocol.fields[field.parent] then - Protocol.fields[field.parent].nameStale = true - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent - local minTimeout = getTime() + Protocol.fieldResponseTimeout() - if Protocol.fieldTimeout < minTimeout then - Protocol.fieldTimeout = minTimeout - end - end -end - -function Protocol.reloadRelatedFields(field) - Protocol.reloadParentFolder(field) - - for fieldId = Protocol.fieldsCount, 1, -1 do - local sibling = Protocol.fields[fieldId] - local siblingType = sibling.type or 99 - if - fieldId ~= field.id - and sibling.parent == field.parent - and (siblingType < crsf.CONST.FIELD_FOLDER or siblingType == crsf.CONST.FIELD_INFO) - then - sibling.dirty = true - sibling.reloading = true - Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId - end - end - - field.dirty = true - field.reloading = true - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id - Protocol.fieldTimeout = getTime() + 20 - Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 -end - -function Protocol.handleCommandSave(field) - Protocol.reloadCurField(field) - - if field.status ~= nil then - if field.status < crsf.CONST.CMD_CONFIRMED then - field.status = crsf.CONST.CMD_CLICK - crsf.push(fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, field.id, crsf.CONST.CMD_CLICK)) - Protocol.fieldPopup = field - Protocol.fieldPopup.lastStatus = crsf.CONST.CMD_IDLE - Protocol.fieldTimeout = getTime() + field.timeout - end - end -end - -function Protocol.commandConfirm() - if Protocol.fieldPopup then - crsf.push( - fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CONFIRMED) - ) - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - Protocol.fieldPopup.status = crsf.CONST.CMD_CONFIRMED - end -end - --- Cancel and dismiss: sends CMD_CANCEL and drops the popup immediately. -function Protocol.commandCancel() - if Protocol.fieldPopup then - crsf.push( - fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) - ) - Protocol.fieldPopup = nil - end -end - --- Cancel but keep the popup: sends CMD_CANCEL and waits for the device to --- report CMD_IDLE, which dismisses the popup through the entry decode. Used --- while no dialog is on screen yet (e.g. right after CMD_CLICK), so the UI --- keeps tracking the device's actual command state. -function Protocol.commandRequestCancel() - if Protocol.fieldPopup then - crsf.push( - fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_CANCEL) - ) - Protocol.fieldTimeout = getTime() + 200 - end -end - --- Clear the ELRS critical-error banner: optimistic local clear plus the --- suppress write the module acts on. -function Protocol.suppressCriticalErrors() - Protocol.elrsFlags = 0 - crsf.push(fields.encodeSuppressCriticalErrors(Protocol.deviceId, Protocol.handsetId)) -end - --- ============================================================================ --- CRSF message parsing --- ============================================================================ - -function Protocol.parseDeviceInfoMessage(data) - local info = crsf:decodeDeviceInfo(data) - if not info then - return nil - end - local device = Protocol.getDevice(info.id) - local isNew = (device == nil) - if isNew then - device = { id = info.id } - Protocol.devices[#Protocol.devices + 1] = device - end - device.name = info.name - device.fieldCount = info.fieldCount - device.isElrs = info.isElrs - return device, isNew -end - --- Handle a parameter settings entry (0x2B) frame. Field data can span several --- frames; the library reassembles the chunks before this decodes one field. -function Protocol.parseParameterInfoMessage(data) - local expectedId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] - local fieldId, buffer, offset = fields.reassemble(Protocol.rx, Protocol.deviceId, data, expectedId) - if not fieldId then - return - end - local field = Protocol.fields[fieldId] - if not field then - return - end - if not buffer then - -- Chunk consumed; the next read goes out with the updated chunk index - return - end - - -- Field data stream is now complete, process into a field - Protocol.loadQueue[#Protocol.loadQueue] = nil - - -- Hidden-bit changes rebuild the UI's cached list of visible fields, so - -- track it across the decode. - local wasHidden = field.hidden - -- Passing the old name makes the decoder skip its read and reuse that string, - -- which is only safe while no reload has flagged the name as possibly changed - local cachedName = (not field.nameStale and not field.reloading) and field.name or nil - if fields.decodeEntry(field, fieldId, buffer, offset, cachedName) then - field.nameStale = nil - field.reloading = nil - if field.hidden ~= wasHidden then - Protocol.fieldHiddenChanged = true - end - - if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE then - -- A command that was actively running just finished (or was cancelled): - -- re-read its same-level fields so the current page reflects any values - -- the command changed. The guard limits this to the active command -- - -- routine loads of idle command fields while browsing (fieldPopup is - -- nil) must not trigger a reload. - if Protocol.fieldPopup == field then - Protocol.reloadRelatedFields(field) - end - Protocol.fieldPopup = nil - end - - -- Auto-queue children for root folder (field 0) and during background preloading. - if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then - for i = #field.children, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] - end - end - end -end - -function Protocol.parseElrsInfoMessage(data) - local status = crsf:decodeElrsStatus(data) - if not status then - return - end - if status.id ~= Protocol.deviceId then - fields.resetChunks(Protocol.rx) - return - end - - Protocol.lostPackets = status.lostPackets - Protocol.receivedPackets = status.receivedPackets - Protocol.elrsFlags = status.flags - Protocol.connected = status.connected - Protocol.modelMismatch = status.modelMismatch - Protocol.criticalError = status.criticalError - Protocol.elrsFlagsInfo = status.warning -end - --- ============================================================================ --- Main CRSF communication loop --- ============================================================================ - -function Protocol.poll() - local command, data - local targetDevice = nil - local anyNewDevice = false - - repeat - command, data = crsf.pop() - if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then - local device, isNew = Protocol.parseDeviceInfoMessage(data) - if device then - if device.id == Protocol.deviceId then - targetDevice = device - end - if isNew then - anyNewDevice = true - end - end - elseif command == crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then - Protocol.parseParameterInfoMessage(data) - if #Protocol.loadQueue > 0 then - Protocol.fieldTimeout = 0 - elseif Protocol.fieldPopup then - Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout - end - elseif command == crsf.CONST.FRAMETYPE_PARAMETER_WRITE then - if crsf:isElrsV1Frame(data) then - Protocol.elrsV1Detected = true - end - elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then - Protocol.parseElrsInfoMessage(data) - end - until command == nil - - return targetDevice, anyNewDevice -end - -function Protocol.tick() - -- Ping on telemetry transition (device may have changed) - local connected = Protocol.connected - if connected and not Protocol.hadTelemetry then - crsf:pingDevices() - end - Protocol.hadTelemetry = connected - - local time = getTime() - -- Periodic ping for initial device discovery - if #Protocol.devices == 0 and time > Protocol.pingTimeout then - crsf:pingDevices() - Protocol.pingTimeout = time + 100 -- 1s - end - - if Protocol.fieldPopup then - if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= crsf.CONST.CMD_ASKCONFIRM then - crsf.push( - fields.encodeCommandStep(Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, crsf.CONST.CMD_QUERY) - ) - Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout - end - elseif time > Protocol.linkstatTimeout then - if Protocol.deviceIsELRS_TX then - -- deviceIsELRS_TX guarantees deviceId/handsetId are ADDRESS_TX and - -- ADDRESS_HANDSET_ELRS here (see setDevice), the addressing - -- requestElrsStatus() hardcodes. - crsf:requestElrsStatus() - else - Protocol.receivedPackets = nil - Protocol.lostPackets = nil - end - Protocol.linkstatTimeout = time + 100 - elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then - if #Protocol.loadQueue > 0 then - crsf.push( - fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue]) - ) - Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() - else - Protocol.backgroundLoading = false - end - end -end - -return Protocol diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index d0cd142..6fae5a7 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -7,9 +7,8 @@ local deps = ... local App = deps.App local Navigation = deps.Navigation -local Protocol = deps.Protocol +local session = deps.session local crsf = deps.crsf -local fields = deps.fields local VERSION = deps.VERSION local versionCheckResult = nil @@ -162,7 +161,7 @@ function UI.render(event, _touchState) -- Warning flashing timer local time = getTime() if time > UI.titleShowWarnTimeout then - UI.titleShowWarn = (Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarn = (session.status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil UI.titleShowWarnTimeout = time + 100 UI.forceRedraw = true end @@ -173,7 +172,7 @@ function UI.render(event, _touchState) end -- Model mismatch alert (full-screen, blocks normal rendering) - if Protocol.modelMismatch and not UI.warningDismissedAt then + if session.status.modelMismatch and not UI.warningDismissedAt then if event == EVT_VIRTUAL_ENTER then UI.warningDismissedAt = getTime() UI.forceRedraw = true @@ -195,14 +194,14 @@ function UI.render(event, _touchState) -- more on the frame it empties. poll() pops the last entry before we get here, -- so without the trailing edge the response that completes a reload never -- reaches the screen and the page waits for the next event or warn tick. - local loading = #Protocol.loadQueue > 0 + local loading = session:isLoading() if loading or UI.wasLoading then UI.forceRedraw = true end UI.wasLoading = loading -- Render: command popup or normal page - if Protocol.fieldPopup ~= nil then + if session.command ~= nil then UI.drawPopup(event) elseif event ~= 0 or UI.forceRedraw or UI.edit then UI.drawPage(event) @@ -262,9 +261,9 @@ function UI.handleBack() UI.lineIndex = entry.li or 1 UI.pageOffset = entry.po or 0 if entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then - local prevDevice = Protocol.getDevice(entry.prevDeviceId) + local prevDevice = session:getDevice(entry.prevDeviceId) if prevDevice then - Protocol.setDevice(prevDevice) + session:setDevice(prevDevice) end end end @@ -281,21 +280,21 @@ function UI.buildVisibleFields() local vf = {} if currentFolder == Navigation.FOLDER_OTHER_DEVICES then - for _, device in ipairs(Protocol.devices) do - if device.id ~= Protocol.deviceId then - vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.DEVICE } + for _, device in ipairs(session.devices) do + if device.id ~= session.deviceId then + vf[#vf + 1] = { id = device.id, name = device.name, type = App.DEVICE } end end else - local fields = Protocol.getFieldsInFolder(currentFolder) + local fields = session:fieldsInFolder(currentFolder) for _, field in ipairs(fields) do if not field.hidden then vf[#vf + 1] = field end end - if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then - vf[#vf + 1] = { name = "Other Devices", type = Protocol.DEVICE_FOLDER } + if currentFolder == nil and #session.devices > 1 and not Navigation.hasDeviceEntry() then + vf[#vf + 1] = { name = "Other Devices", type = App.DEVICE_FOLDER } end end @@ -438,8 +437,8 @@ displayHandlers[crsf.CONST.FIELD_STRING] = fieldStringDisplay displayHandlers[crsf.CONST.FIELD_INFO] = fieldStringDisplay displayHandlers[crsf.CONST.FIELD_FOLDER] = fieldFolderDisplay displayHandlers[crsf.CONST.FIELD_COMMAND] = fieldCommandDisplay -displayHandlers[Protocol.DEVICE] = fieldCommandDisplay -displayHandlers[Protocol.DEVICE_FOLDER] = fieldFolderDisplay +displayHandlers[App.DEVICE] = fieldCommandDisplay +displayHandlers[App.DEVICE_FOLDER] = fieldFolderDisplay -- ============================================================================ -- Title bar drawing @@ -448,12 +447,13 @@ displayHandlers[Protocol.DEVICE_FOLDER] = fieldFolderDisplay function UI.drawTitle() local barHeight = 9 local goodBadPkt = "" - if Protocol.receivedPackets then - local state = Protocol.connected and "C" or "-" - goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) + local status = session.status + if status.receivedPackets then + local state = status.connected and "C" or "-" + goodBadPkt = string.format("%u/%u %s", status.lostPackets, status.receivedPackets, state) end - local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + local loaded, total = session:folderLoadProgress(Navigation.getCurrent()) if not UI.titleShowWarn then lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight - 1, SOLID, INVERS) @@ -465,9 +465,9 @@ function UI.drawTitle() else lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) if UI.titleShowWarn then - lcd.drawText(UI.COL1, 1, Protocol.elrsFlagsInfo, INVERS) + lcd.drawText(UI.COL1, 1, session.status.warning, INVERS) else - lcd.drawText(UI.COL1, 1, Protocol.deviceName or "Searching...", INVERS) + lcd.drawText(UI.COL1, 1, session.deviceName or "Searching...", INVERS) end end end @@ -478,7 +478,7 @@ end function UI.drawWarning() lcd.drawText(UI.COL1, UI.textSize * 2, "Error:") - lcd.drawText(UI.COL1, UI.textSize * 3, Protocol.elrsFlagsInfo) + lcd.drawText(UI.COL1, UI.textSize * 3, session.status.warning) lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER) end @@ -492,14 +492,14 @@ function UI.handleEvent(event) UI.edit = nil local field = UI.getField(UI.lineIndex) if field and field.id then - Protocol.reloadCurField(field) + session:reloadField(field) end else UI.handleBack() end elseif event == EVT_VIRTUAL_ENTER then - if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then - Protocol.suppressCriticalErrors() + if session.status.flags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then + session:suppressCriticalErrors() elseif UI.isOnBackExit() then if Navigation.isAtRoot() then App.shouldExit = true @@ -513,17 +513,16 @@ function UI.handleEvent(event) if ft == crsf.CONST.FIELD_FOLDER then UI.openFolder(field.id, field.name) - elseif ft == Protocol.DEVICE_FOLDER then + elseif ft == App.DEVICE_FOLDER then UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") - elseif ft == Protocol.DEVICE then + elseif ft == App.DEVICE then UI.switchDevice(field.id) elseif ft == crsf.CONST.FIELD_COMMAND then - Protocol.handleCommandSave(field) + session:execCommand(field) elseif not field.disabled and ft <= crsf.CONST.FIELD_TEXT_SELECTION then UI.edit = not UI.edit if not UI.edit then - crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) - Protocol.reloadRelatedFields(field) + session:writeField(field) end end end @@ -553,7 +552,7 @@ function UI.drawPage(event) lcd.clear() UI.drawTitle() - if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then + if session.status.flags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then UI.drawWarning() else local totalCount = UI.getSelectableCount() @@ -590,39 +589,35 @@ end -- ============================================================================ function UI.drawPopup(event) + local command = session.command if event == EVT_VIRTUAL_EXIT then - local status = Protocol.fieldPopup.status + local status = command.status if status ~= crsf.CONST.CMD_ASKCONFIRM and status ~= crsf.CONST.CMD_EXECUTING then -- No dialog is on screen yet (e.g. CMD_CLICK just went out): request the -- cancel but keep the popup up until the device reports CMD_IDLE. The -- dialog branches below handle their own cancel via popupConfirmation. - Protocol.commandRequestCancel() + session:requestCancelCommand() end end - if Protocol.fieldPopup.status == crsf.CONST.CMD_ASKCONFIRM then - local result = popupConfirmation(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if command.status == crsf.CONST.CMD_ASKCONFIRM then + local result = popupConfirmation(command.info or "", "PRESS [OK] to confirm", event) if result == "OK" then - Protocol.commandConfirm() + session:confirmCommand() elseif result == "CANCEL" then - Protocol.commandCancel() + session:cancelCommand() end - elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then - if Protocol.rx.chunk == 0 then + elseif command.status == crsf.CONST.CMD_EXECUTING then + if not session:isReceivingChunks() then UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 end local result = popupConfirmation( - (Protocol.fieldPopup.info or "") - .. " [" - .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) - .. "]", + (command.info or "") .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]", "Press [RTN] to exit", event ) - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status if result == "CANCEL" then - Protocol.commandCancel() + session:cancelCommand() end end end diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 12c169b..1622870 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -7,9 +7,8 @@ local deps = ... local App = deps.App local Navigation = deps.Navigation -local Protocol = deps.Protocol +local session = deps.session local crsf = deps.crsf -local fields = deps.fields local VERSION = deps.VERSION -- ============================================================================ @@ -21,10 +20,13 @@ local UI = { uiBuilt = false, folderWasReady = false, - -- Warning/command state (LVGL-specific) + -- Warning/command state (LVGL-specific). cmdLastStatus remembers which + -- command status the current dialog was built for, so a status change + -- swaps the dialog exactly once. warningDismissedAt = nil, warningDialog = nil, commandDialog = nil, + cmdLastStatus = nil, } -- ============================================================================ @@ -514,9 +516,9 @@ function UI.handleBack() else local entry = App.goBack() if entry and entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then - local prevDevice = Protocol.getDevice(entry.prevDeviceId) + local prevDevice = session:getDevice(entry.prevDeviceId) if prevDevice then - Protocol.setDevice(prevDevice) + session:setDevice(prevDevice) end end UI.invalidate() @@ -528,39 +530,38 @@ end -- ============================================================================ local function onCommandCancel() - Protocol.commandCancel() + session:cancelCommand() UI.commandDialog = nil UI.invalidate() end local function handleCommandPopup() - if not Protocol.fieldPopup then + local command = session.command + if not command then if UI.commandDialog then UI.commandDialog = nil UI.invalidate() end + UI.cmdLastStatus = nil return end - if Protocol.fieldPopup.status == crsf.CONST.CMD_ASKCONFIRM then - if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= crsf.CONST.CMD_ASKCONFIRM then - local field = Protocol.fieldPopup - UI.commandDialog = CommandPage.showConfirm(field.name, function() - return field.info or "" + if command.status == crsf.CONST.CMD_ASKCONFIRM then + if not UI.commandDialog or UI.cmdLastStatus ~= crsf.CONST.CMD_ASKCONFIRM then + UI.commandDialog = CommandPage.showConfirm(command.name, function() + return command.info or "" end, function() - Protocol.commandConfirm() + session:confirmCommand() end, onCommandCancel) end - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status - elseif Protocol.fieldPopup.status == crsf.CONST.CMD_EXECUTING then - if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= crsf.CONST.CMD_EXECUTING then - local field = Protocol.fieldPopup - UI.commandDialog = CommandPage.showExecuting(field.name, function() - return field.info or "" + elseif command.status == crsf.CONST.CMD_EXECUTING then + if not UI.commandDialog or UI.cmdLastStatus ~= crsf.CONST.CMD_EXECUTING then + UI.commandDialog = CommandPage.showExecuting(command.name, function() + return command.info or "" end, onCommandCancel) end - Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status end + UI.cmdLastStatus = command.status end -- ============================================================================ @@ -571,19 +572,19 @@ local function handleWarning() if App.shouldExit then return end - if Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then + if session.status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then if not UI.warningDialog and not UI.warningDismissedAt then - if Protocol.modelMismatch then + if session.status.modelMismatch then UI.warningDialog = ModelMismatchDialog.show(function() UI.warningDismissedAt = getTime() UI.invalidate() end, function() App.shouldExit = true end) - elseif Protocol.criticalError then + elseif session.status.criticalError then Dialogs.showMessage({ title = "Warning", - message = Protocol.elrsFlagsInfo, + message = session.status.warning, }) UI.warningDialog = true UI.warningDismissedAt = getTime() @@ -611,7 +612,7 @@ function UI.render(_event, _touchState) if not UI.commandDialog then handleWarning() - if not UI.uiBuilt and #Protocol.fields > 0 then + if not UI.uiBuilt and session.fieldsCount > 0 then UI.build() end end @@ -626,7 +627,7 @@ function UI.getSubtitle() local top = Navigation.stack[#Navigation.stack] local subtitleParts = { top.name or "" } - local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + local loaded, total = session:folderLoadProgress(Navigation.getCurrent()) if loaded and loaded < total then subtitleParts[#subtitleParts + 1] = string.format(" • Loading %d%%", math.floor(loaded / total * 100)) end @@ -634,26 +635,23 @@ function UI.getSubtitle() return table.concat(subtitleParts) end - local loaded, total = Protocol.getFolderLoadProgress(nil) - if loaded and loaded < total and Protocol.fieldsCount > 0 then + local loaded, total = session:folderLoadProgress(nil) + if loaded and loaded < total and session.fieldsCount > 0 then return string.format("Loading %d%%", math.floor(loaded / total * 100)) end + local status = session.status local subtitle = "" - if Protocol.receivedPackets then - local state = Protocol.connected and "Telemetry OK" or "No telemetry" - subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) + if status.receivedPackets then + local state = status.connected and "Telemetry OK" or "No telemetry" + subtitle = string.format("%u/%u • %s", status.lostPackets, status.receivedPackets, state) end - if - Protocol.elrsFlags > crsf.CONST.ELRS_FLAGS_STATUS_MASK - and Protocol.elrsFlagsInfo - and Protocol.elrsFlagsInfo ~= "" - then + if status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and status.warning and status.warning ~= "" then if subtitle ~= "" then - subtitle = table.concat({ subtitle, " • ", Protocol.elrsFlagsInfo }) + subtitle = table.concat({ subtitle, " • ", status.warning }) else - subtitle = Protocol.elrsFlagsInfo + subtitle = status.warning end end @@ -701,8 +699,7 @@ function UI.createToggleRow(pg, field) end, set = function(val) field.value = val - crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) - Protocol.reloadRelatedFields(field) + session:writeField(field) end, active = function() return not field.disabled @@ -766,8 +763,7 @@ function UI.createChoiceRow(pg, field) end, set = function(val) field.value = val - 1 - crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) - Protocol.reloadRelatedFields(field) + session:writeField(field) end, active = function() return not field.disabled @@ -805,8 +801,7 @@ function UI.createNumberRow(pg, field) end, edited = function(val) field.value = val - crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, field)) - Protocol.reloadRelatedFields(field) + session:writeField(field) end, display = function(val) if isFloat then @@ -901,8 +896,7 @@ function UI.createStringRow(pg, field) length = math.min(math.max(field.maxlen or 32, 32), 128), set = function(val) field.value = val - crsf.push(fields.encodeWriteString(Protocol.deviceId, Protocol.handsetId, field)) - Protocol.reloadRelatedFields(field) + session:writeField(field) end, active = function() return not field.disabled @@ -945,7 +939,7 @@ function UI.createCommandWidget(pg, field) end, w = lvgl.PERCENT_SIZE + 99, press = function() - Protocol.handleCommandSave(field) + session:execCommand(field) end, }) end @@ -1020,8 +1014,8 @@ function UI.build() flexPad = lvgl.PAD_SMALL, borderPad = lvgl.PAD_TINY, }) - for _, device in ipairs(Protocol.devices) do - if device.id ~= Protocol.deviceId then + for _, device in ipairs(session.devices) do + if device.id ~= session.deviceId then devicesBox:button({ text = device.name or "Unknown", w = lvgl.PERCENT_SIZE + 100, @@ -1032,10 +1026,10 @@ function UI.build() end end else - local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) + local fieldsInFolder = session:fieldsInFolder(currentFolder) if currentFolder == nil then - UI.createInfoRow(fieldContainer, { name = "Device name", value = Protocol.deviceName or "Searching..." }) + UI.createInfoRow(fieldContainer, { name = "Device name", value = session.deviceName or "Searching..." }) end local FOLDERS_PER_ROW = 2 @@ -1085,11 +1079,11 @@ function UI.build() end end - if currentFolder == nil and Protocol.deviceIsELRS_TX then + if currentFolder == nil and session.isElrsTx then UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) end - if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + if currentFolder == nil and #session.devices > 1 and not Navigation.hasDeviceEntry() then local wrapper = fieldContainer:box({ w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, From 3bec451b901f606dc2fe89b8c2a4cb26f32c621b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 18:02:32 +0300 Subject: [PATCH 166/218] Run VTX Admin on a CRSF parameter session Each widget instance owns a CRSFSession in passive fan-out mode (acceptUnsolicited): discovery re-derives from the session's field store through the onFieldUpdate callback, writes go through the paced write queue (STATE_SENDING exits when isWriting() clears), and the bounded folder read-back is the session's refresh slot. The widget keeps only its state machine, statusText and write policy. Two session rules surfaced by running many instances on one bus: a fan-out session must decode folder names fresh (no staleness flag can cover a sibling's write rewriting the name -- non-folder names stay cached, they are static), and the root-children auto-queue must only trigger off the session's own read, or every sibling's root answer multiplies the load traffic by the instance count. --- src/SCRIPTS/ELRS/crsf_session.lua | 30 +++- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 214 +++++++------------------- src/WIDGETS/ELRSVTXAdmin/main.lua | 9 +- 3 files changed, 82 insertions(+), 171 deletions(-) diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua index 76c36d2..ff007d6 100644 --- a/src/SCRIPTS/ELRS/crsf_session.lua +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -280,7 +280,8 @@ function CRSFSession:_onEntry(data) -- Entry complete: it settles the queue head only when it answers it -- a -- command status elicited while loads are pending must not pop an -- unrelated field - if self._loadQueue[#self._loadQueue] == fieldId then + local answeredHead = self._loadQueue[#self._loadQueue] == fieldId + if answeredHead then self._loadQueue[#self._loadQueue] = nil end if self._refreshId == fieldId then @@ -297,9 +298,18 @@ function CRSFSession:_onEntry(data) -- track it across the decode. local wasHidden = field.hidden -- Passing the old name makes the decoder skip its read and reuse that - -- string, which is only safe while no reload has flagged the name as - -- possibly changed - local cachedName = (not field.nameStale and not field.reloading) and field.name or nil + -- string. In strict mode any name change is flagged first + -- (nameStale/reloading). A passive fan-out session also decodes entries + -- it never asked for, so no flag can cover a change -- but only folder + -- names embed values (ExpressLRS rewrites them on writes); every other + -- name is static, and caching it keeps the fan-out path cheap enough for + -- many sessions sharing one bus. + local cachedName + if not self._acceptUnsolicited then + cachedName = (not field.nameStale and not field.reloading) and field.name or nil + elseif field.type ~= crsf.CONST.FIELD_FOLDER then + cachedName = field.name + end if params.decodeEntry(field, fieldId, buffer, offset, cachedName) then field.nameStale = nil field.reloading = nil @@ -317,8 +327,16 @@ function CRSFSession:_onEntry(data) self.command = nil end - -- Auto-queue children for the root folder and during preloading - if field.type == crsf.CONST.FIELD_FOLDER and field.children and (fieldId == 0 or self._preloading) then + -- Auto-queue children for the root folder and during preloading -- but + -- only off the answer to our own read: under the fan-out, sessions also + -- see every sibling's root answers, and re-queueing the children each + -- time would multiply the load traffic by the instance count + if + answeredHead + and field.type == crsf.CONST.FIELD_FOLDER + and field.children + and (fieldId == 0 or self._preloading) + then for i = #field.children, 1, -1 do self._loadQueue[#self._loadQueue + 1] = field.children[i] end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 527665c..3045ccf 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -9,7 +9,7 @@ -- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H. -- --------------------------------------------------------------------------- -local zone, options, crsf, fields = ... +local zone, options, crsf, CRSFSession = ... -- Forward declarations for modules (needed for cross-references) local VTX @@ -137,9 +137,14 @@ function VTX.syncDesiredFromState() end -- ============================================================================ --- Protocol Module: CRSF config protocol, state machine, discovery, write queue +-- Protocol Module: discovery state machine and write policy over a session -- ============================================================================ +-- Every widget instance owns a CRSF parameter session in passive fan-out +-- mode: any field from the TX module is accepted, so sibling instances see +-- every response they did not request themselves. +local session + Protocol = { -- State machine constants STATE_INIT = 0, @@ -154,34 +159,6 @@ Protocol = { state = 0, -- STATE_INIT statusText = "Initializing...", - -- CRSF addressing plus the crsf_params.lua reassembly state the codec - -- manages (rx.chunk doubles as the next chunk index encodeRead carries, - -- giving multi-chunk entries their follow-up reads). - deviceId = crsf.CONST.ADDRESS_TX, - handsetId = crsf.CONST.ADDRESS_HANDSET_ELRS, - rx = { chunk = 0, expect = -1 }, - - -- Discovery - loadQueue = {}, - rootChildren = {}, - vtxChildren = {}, - fieldTimeout = 0, - discoveredFields = {}, - - -- Write queue - writeQueue = {}, - writeIdx = 0, - lastWriteTime = 0, - - -- Folder re-read: event-driven, never periodic. armFolderRead() sets the - -- deadline after our own writes and on resume from suspension; the folder - -- response clears it, and an unanswered read retries a bounded number of - -- times so a lost frame cannot leave the display stale. - ---@type number? - folderReadPending = nil, - folderReadAttempts = 0, - FOLDER_READ_RETRIES = 3, - -- Previous tick timestamp, to detect suspension: a standalone tool pauses -- widget scripts, and whatever it changed needs one read-back on resume. lastTick = 0, @@ -207,89 +184,29 @@ function Protocol.hasModule() return Protocol.state ~= Protocol.STATE_NO_MODULE end --- Response timeout for PARAMETER_READ: 0.5s for local TX module. -function Protocol.fieldResponseTimeout() - return 50 -end - ---- Arm a folder re-read `delay` ticks from now (0 = next tick). ---- Every push replaces one RC-channels frame on the handset->module UART, so ---- the folder is only ever read when something can have changed it: after our ---- own writes, and once on resume from suspension. Sibling widget instances ---- need nothing — every instance's onSettingsEntry sees every response. -function Protocol.armFolderRead(delay) - Protocol.folderReadPending = getTime() + delay - Protocol.folderReadAttempts = 0 - -- A fresh read must not inherit chunk state from an interrupted one (e.g. - -- resume from suspension mid-entry). - fields.resetChunks(Protocol.rx) -end - -- ============================================================================ --- Protocol: Response handler (registered on singleton dispatcher) +-- Protocol: field handler (session onFieldUpdate callback) -- ============================================================================ -function Protocol.onSettingsEntry(data) - -- Passive fan-out mode: accept any field from our device (data[3] as the - -- expected id) -- sibling widget instances depend on seeing responses they - -- did not request. The codec gates on the device address and keeps - -- cross-field continuations out of an in-flight reassembly buffer. - local fieldId, buffer, offset = fields.reassemble(Protocol.rx, Protocol.deviceId, data, data[3]) - if not fieldId then - return - end - if not buffer then - -- Chunk consumed: hurry the follow-up read, which carries the updated - -- chunk index. The device is answering, so no retry attempt is burned. - if Protocol.state >= Protocol.STATE_DISCOVER_ROOT and Protocol.state <= Protocol.STATE_DISCOVER_VTX then - Protocol.fieldTimeout = 0 - elseif Protocol.folderReadPending then - Protocol.folderReadPending = getTime() - Protocol.folderReadAttempts = 0 - end - return - end - - local field = Protocol.discoveredFields[fieldId] - if not field then - field = {} - Protocol.discoveredFields[fieldId] = field - end - if not fields.decodeEntry(field, fieldId, buffer, offset) then - return - end - local fieldType = field.type +function Protocol.onField(field) + local fieldId = field.id local fieldName = field.name - - -- Pop the field from load queue if it matches - if #Protocol.loadQueue > 0 and Protocol.loadQueue[#Protocol.loadQueue] == fieldId then - Protocol.loadQueue[#Protocol.loadQueue] = nil - Protocol.fieldTimeout = 0 - end - local st = Protocol.state if st == Protocol.STATE_DISCOVER_ROOT then - if fieldId == 0 and fieldType == crsf.CONST.FIELD_FOLDER then - Protocol.rootChildren = field.children or {} - for i = #Protocol.rootChildren, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.rootChildren[i] - end + if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then + -- The session auto-queues the root children off this entry Protocol.state = Protocol.STATE_DISCOVER_CHILDREN Protocol.statusText = "Discovering fields..." end elseif st == Protocol.STATE_DISCOVER_CHILDREN then - if fieldType == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then + if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then VTX.ids.folder = fieldId - Protocol.vtxChildren = field.children or {} VTX.parseFolderName(fieldName) - for i = #Protocol.vtxChildren, 1, -1 do - Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.vtxChildren[i] - end + session:loadFolder(fieldId) Protocol.state = Protocol.STATE_DISCOVER_VTX Protocol.statusText = "Loading VTX fields..." - end - if #Protocol.loadQueue == 0 and VTX.ids.folder == nil then + elseif not session:isLoading() and VTX.ids.folder == nil then Protocol.statusText = "VTX Admin not found" end elseif st == Protocol.STATE_DISCOVER_VTX then @@ -305,7 +222,7 @@ function Protocol.onSettingsEntry(data) VTX.ids.send = fieldId end - if #Protocol.loadQueue == 0 then + if not session:isLoading() then if VTX.ids.band and VTX.ids.channel and VTX.ids.power and VTX.ids.pitmode and VTX.ids.send then Protocol.state = Protocol.STATE_READY Protocol.statusText = "" @@ -316,14 +233,18 @@ function Protocol.onSettingsEntry(data) end elseif st == Protocol.STATE_READY then if fieldId == VTX.ids.folder then - Protocol.folderReadPending = nil VTX.parseFolderName(fieldName) end end end --- Register on the shared CRSF singleton -crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, Protocol.onSettingsEntry) +session = CRSFSession.new({ + acceptUnsolicited = true, + responseTimeout = 50, -- always the local TX module + onFieldUpdate = Protocol.onField, +}) +-- Register on the shared CRSF singleton's fan-out +session:attachBus() -- ============================================================================ -- Protocol: State machine tick @@ -334,9 +255,12 @@ function Protocol.tick() -- A tick gap over a second means the widget was suspended — a standalone -- tool had the screen and may have changed the module config — so read the - -- folder back once on resume. + -- folder back once on resume. The bounded refresh slot retries a lost + -- frame without ever polling: every push replaces one RC-channels frame + -- on the handset->module UART, so the folder is only read when something + -- can have changed it. if Protocol.lastTick > 0 and now - Protocol.lastTick > 100 and Protocol.state == Protocol.STATE_READY then - Protocol.armFolderRead(0) + session:refreshField(VTX.ids.folder, 0, 3) end Protocol.lastTick = now @@ -346,51 +270,20 @@ function Protocol.tick() if crsf.hasCrsfModule() then Protocol.state = Protocol.STATE_DISCOVER_ROOT Protocol.statusText = "Discovering..." - Protocol.loadQueue = { 0 } - Protocol.fieldTimeout = 0 + session:reloadAll() else Protocol.state = Protocol.STATE_NO_MODULE Protocol.statusText = "No CRSF module" end - elseif - st == Protocol.STATE_DISCOVER_ROOT - or st == Protocol.STATE_DISCOVER_CHILDREN - or st == Protocol.STATE_DISCOVER_VTX - then - if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then - local fieldId = Protocol.loadQueue[#Protocol.loadQueue] - crsf.push(fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, fieldId)) - Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() - end - elseif st == Protocol.STATE_READY then - if Protocol.folderReadPending and now >= Protocol.folderReadPending then - if Protocol.folderReadAttempts < Protocol.FOLDER_READ_RETRIES then - Protocol.folderReadAttempts = Protocol.folderReadAttempts + 1 - Protocol.folderReadPending = now + Protocol.fieldResponseTimeout() - crsf.push(fields.encodeRead(Protocol.rx, Protocol.deviceId, Protocol.handsetId, VTX.ids.folder)) - else - Protocol.folderReadPending = nil - end - end - elseif st == Protocol.STATE_SENDING then - if Protocol.writeIdx <= #Protocol.writeQueue then - if now - Protocol.lastWriteTime >= 5 then -- 50ms - local entry = Protocol.writeQueue[Protocol.writeIdx] - print(table.concat({ "VTXAdmin: writing field=", entry.id, " val=", entry.value })) - crsf.push(fields.encodeWriteInt(Protocol.deviceId, Protocol.handsetId, entry)) - Protocol.lastWriteTime = now - Protocol.writeIdx = Protocol.writeIdx + 1 - end - else - print(table.concat({ "VTXAdmin: write queue complete, ", #Protocol.writeQueue, " entries sent" })) - Protocol.writeQueue = {} - Protocol.writeIdx = 0 - Protocol.state = Protocol.STATE_READY - -- Read the folder back ~100ms after the last write so the module has - -- applied the change; the simulator defers folder-name updates ~20ms. - Protocol.armFolderRead(10) - end + elseif st == Protocol.STATE_SENDING and not session:isWriting() then + print("VTXAdmin: write queue drained") + Protocol.state = Protocol.STATE_READY + -- Read the folder back ~100ms after the last write so the module has + -- applied the change; the simulator defers folder-name updates ~20ms. + session:refreshField(VTX.ids.folder, 10, 3) end + + session:tick() end -- ============================================================================ @@ -407,7 +300,6 @@ function Protocol.writeConfig() local s = VTX.state local d = VTX.desired - Protocol.writeQueue = {} print(table.concat({ "VTXAdmin: writeConfig() desired: band=", @@ -430,46 +322,44 @@ function Protocol.writeConfig() tostring(s.pitmode), })) + local wrote = 0 if d.band ~= s.band then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.band, value = d.band } + session:writeField({ id = VTX.ids.band, value = d.band }) + wrote = wrote + 1 end if d.channel ~= s.channel then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.channel, value = d.channel } + session:writeField({ id = VTX.ids.channel, value = d.channel }) + wrote = wrote + 1 end if d.power ~= s.power then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.power, value = d.power } + session:writeField({ id = VTX.ids.power, value = d.power }) + wrote = wrote + 1 end local desiredPit = d.pitmode local currentPit = s.pitmode and 1 or 0 if desiredPit ~= currentPit then - Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.pitmode, value = desiredPit } + session:writeField({ id = VTX.ids.pitmode, value = desiredPit }) + wrote = wrote + 1 end - print(table.concat({ "VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)" })) + print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) - if #Protocol.writeQueue > 0 then - Protocol.writeIdx = 1 - Protocol.lastWriteTime = 0 + if wrote > 0 then Protocol.state = Protocol.STATE_SENDING end end ---- Append the "Send VTx" command to the write queue, pushing config to the VTX. +--- Send the "Send VTx" command, pushing config to the VTX. function Protocol.pushToVtx() if not Protocol.isReady() and Protocol.state ~= Protocol.STATE_SENDING then print("VTXAdmin: pushToVtx() skipped - not ready") return end - print("VTXAdmin: pushToVtx() - queuing Send VTx command") - Protocol.writeQueue[#Protocol.writeQueue + 1] = { id = VTX.ids.send, value = crsf.CONST.CMD_CLICK } - - if Protocol.state ~= Protocol.STATE_SENDING then - Protocol.writeIdx = 1 - Protocol.lastWriteTime = 0 - Protocol.state = Protocol.STATE_SENDING - end + print("VTXAdmin: pushToVtx() - sending Send VTx command") + session:writeField({ id = VTX.ids.send, value = crsf.CONST.CMD_CLICK }) + Protocol.state = Protocol.STATE_SENDING end -- ============================================================================ diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index 9d03779..92ad2cd 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -16,14 +16,17 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end - if not _crsfParamsSingleton then + if not _crsfSessionClass then local getParams = loadScript("/SCRIPTS/ELRS/crsf_params.lua") ---@diagnostic disable-next-line: need-check-nil - _crsfParamsSingleton = getParams(_crsfSingleton) + local params = getParams(_crsfSingleton) + local getSessionClass = loadScript("/SCRIPTS/ELRS/crsf_session.lua") + ---@diagnostic disable-next-line: need-check-nil + _crsfSessionClass = getSessionClass(_crsfSingleton, params) end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton, _crsfParamsSingleton) + return loadable(zone, options, _crsfSingleton, _crsfSessionClass) end local function refresh(widget, event, touchState) From 11b20dfa1467bd0ac9f7e5e40de6992bd1787407 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 22:12:58 +0300 Subject: [PATCH 167/218] Decompose the VTX Admin widget into components Merge the widget's VTX and Protocol tables into a single VTXAdmin component: the client of the ELRS "VTX Administrator" service, owning the discovery state machine, current/desired VTX state, write policy, and the 6POS quick-change and push-trigger automation behind one tick(). The machine state is now "phase" -- VTXAdmin.state is the parsed VTX state. Around it, loadable.lua wires focused components: presets_storage.lua is a pure settings store (preset slots, sources, flags, and their key=value persistence), ui/display.lua carries the VTXDisplay read-model and WidgetLayout builders, ui/fullscreen.lua the full-screen editor, and the schema-free key=value file I/O joins the shared ELRS library as file_storage.lua. Dependencies run one way: FileStorage <- PresetsStorage <- VTXAdmin <- UI. Behavior is unchanged: log lines, the hold-until-ready 6POS latch, the push-trigger first-sample adoption and the presets.txt format all survive verbatim, verified in the simulator against a pre-refactor baseline (discovery, 6POS sweep, trigger edges, save/load roundtrip, no-module, 11 instances). The CRSF simulator mock rides along: it now builds folder-name summaries only for the TX module and mimics the firmware's Fan Thresh visibility rule (hidden while Dynamic power is off). --- docs/development.md | 1 + src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 15 + src/SCRIPTS/ELRS/file_storage.lua | 79 ++ src/WIDGETS/ELRSVTXAdmin/loadable.lua | 1172 ++++-------------- src/WIDGETS/ELRSVTXAdmin/presets_storage.lua | 101 ++ src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 249 ++++ src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua | 323 +++++ src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 12 +- 8 files changed, 1036 insertions(+), 916 deletions(-) create mode 100644 src/SCRIPTS/ELRS/file_storage.lua create mode 100644 src/WIDGETS/ELRSVTXAdmin/presets_storage.lua create mode 100644 src/WIDGETS/ELRSVTXAdmin/ui/display.lua create mode 100644 src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua diff --git a/docs/development.md b/docs/development.md index a74dc61..c592f15 100644 --- a/docs/development.md +++ b/docs/development.md @@ -47,6 +47,7 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | +| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded only by the VTX Admin widget | | `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table: `crsf:poll()` hands the diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index a8b2269..ed04d49 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -887,8 +887,14 @@ end --- Update the dynName field on TX Power and VTX Administrator folders -- so the simulator matches real firmware behavior where folder names show -- a summary of the current settings in parentheses. +-- Only the TX module builds these summaries; receiver parameters reuse the +-- same ids for unrelated settings, so the ids below are meaningless there. -- @param device the device table whose params to update local function updateFolderNames(device) + if device.id ~= CRSF.ADDRESS_TX then + return + end + -- TX Power folder (id=6): children Max Power (id=7), Dynamic (id=8) local txPwrFolder = findParam(device, 6) local maxPower = findParam(device, 7) @@ -1331,6 +1337,15 @@ local function mockPush(command, data) else param.value = writeValue end + -- Dynamic power off hides Fan Thresh: mimic firmware visibility + -- rules driven by sibling values, so a write can flip a field's + -- hidden bit and the Lua script sees it on the sibling re-read. + if device.id == CRSF.ADDRESS_TX and param.id == 8 then + local fanThresh = findParam(device, 9) + if fanThresh then + fanThresh.hidden = (param.value == 0) or nil + end + end -- Output Mapping per-channel config: mimic firmware behavior -- where changing Output Ch loads sibling values from per-channel config, -- and editing siblings saves back to the current channel's config. diff --git a/src/SCRIPTS/ELRS/file_storage.lua b/src/SCRIPTS/ELRS/file_storage.lua new file mode 100644 index 0000000..1c629d7 --- /dev/null +++ b/src/SCRIPTS/ELRS/file_storage.lua @@ -0,0 +1,79 @@ +--------------------------------------------------------------------------- +-- Key=Value File Storage -- +-- Loaded via loadScript() with no arguments; returns FileStorage. -- +-- -- +-- Generic line-oriented "key=value" persistence for script settings. -- +-- Knows nothing about any schema: keys and values are plain strings, -- +-- typing and defaults belong to the caller. -- +--------------------------------------------------------------------------- + +local FileStorage = {} + +-- A settings file is a handful of short lines; one bounded read keeps the +-- parser simple and caps a corrupt file's blast radius. +local READ_MAX = 512 + +--- Parse a "key=value" line using plain string.find (no regex). +--- Returns key, value strings or nil if no '=' found. +local function parseKV(line) + local eq = string.find(line, "=", 1, true) + if not eq then + return nil, nil + end + return string.sub(line, 1, eq - 1), string.sub(line, eq + 1) +end + +--- Read a key=value file into a table of strings, later duplicates of a +--- key winning. Returns nil when the file cannot be opened. +function FileStorage.read(path) + local f = io.open(path, "r") + if not f then + return nil + end + local data = io.read(f, READ_MAX) + io.close(f) + local kv = {} + if not data or #data == 0 then + return kv + end + -- Split by newlines using plain string.find + local pos = 1 + while pos <= #data do + local nl = string.find(data, "\n", pos, true) + local line + if nl then + line = string.sub(data, pos, nl - 1) + pos = nl + 1 + else + line = string.sub(data, pos) + pos = #data + 1 + end + local key, val = parseKV(line) + if key and val then + kv[key] = val + end + end + return kv +end + +--- Write one "key=value" line per entry of keys, in that order. Values may +--- be strings or numbers; keys absent from values are skipped. Returns true +--- on success, nil when the file cannot be opened for writing. +function FileStorage.write(path, keys, values) + local f = io.open(path, "w") + if not f then + return nil + end + local lines = {} + for i = 1, #keys do + local val = values[keys[i]] + if val ~= nil then + lines[#lines + 1] = table.concat({ keys[i], "=", val, "\n" }) + end + end + io.write(f, table.concat(lines)) + io.close(f) + return true +end + +return FileStorage diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 3045ccf..0339fad 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1,30 +1,68 @@ --------------------------------------------------------------------------- --- VTX Administrator Widget - Core Logic -- +-- VTX Administrator Widget - Core -- -- Loaded via loadScript() from ELRSVTXAdmin/main.lua -- -- -- --- Communicates with the ELRS TX module's VTX Administrator via the -- --- CRSF config protocol (PARAMETER_READ/WRITE). Field IDs are -- --- discovered at runtime by name -- never hardcoded. -- +-- Home of the VTXAdmin component: client of the ELRS TX module's VTX -- +-- Administrator over the CRSF config protocol (PARAMETER_READ/WRITE). -- +-- Field IDs are discovered at runtime by name -- never hardcoded. -- -- -- --- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H. -- +-- Wires the components around it -- presets_storage.lua, ui/display.lua, -- +-- ui/fullscreen.lua, the shared ELRS file_storage library -- and a -- +-- screen-specific minimized layout from ui/ picked by LCD_W/LCD_H, -- +-- then runs the widget lifecycle. Loaded fresh per widget instance, so -- +-- every table here is per-instance state. -- --------------------------------------------------------------------------- local zone, options, crsf, CRSFSession = ... --- Forward declarations for modules (needed for cross-references) -local VTX -local Protocol -local Presets +-- ============================================================================ +-- Components: file storage (shared ELRS library) and 6POS preset settings +-- ============================================================================ + +local FileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua")() +local PresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua")(FileStorage) -- ============================================================================ --- VTX Module: Band lookups, field IDs, current/desired state, folder parsing +-- VTXAdmin: client of the ELRS "VTX Administrator" service on the TX module +-- Discovery state machine, current/desired VTX state, write policy, +-- 6POS quick-change and push-trigger automation -- ============================================================================ -VTX = { - -- Band lookup tables. BAND_LETTERS is 1-based: band 0 has no letter, and its label - -- differs by context ("Off" for a disabled VTX, "--" for an unused 6POS preset slot). - BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, - BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, +-- Every widget instance owns a CRSF parameter session in passive fan-out +-- mode: any field from the TX module is accepted, so sibling instances see +-- every response they did not request themselves. +local session + +local VTXAdmin = { + -- State machine phase constants. "phase" rather than "state": VTXAdmin.state + -- below is the VTX state parsed from the folder name. + PHASE_INIT = 0, + PHASE_NO_MODULE = 1, + PHASE_DISCOVER_ROOT = 2, + PHASE_DISCOVER_CHILDREN = 3, + PHASE_DISCOVER_VTX = 4, + PHASE_READY = 5, + PHASE_SENDING = 6, + + -- Current state machine phase + phase = 0, -- PHASE_INIT + + -- Previous tick timestamp, to detect suspension: a standalone tool pauses + -- widget scripts, and whatever it changed needs one read-back on resume. + lastTick = 0, + + -- 6POS processing state: last consumed position (-1 until the first settle; + -- read by the cheatsheet highlight and the full-screen rebuild tracking) + -- plus the debounce bookkeeping. + lastPos = -1, + stablePos = -1, + stableTime = 0, + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- Push trigger edge detection state. nil until the first sample has been + -- taken; the full-screen editor resets it on trigger-source reassignment. + ---@type boolean? + pushLastHigh = nil, -- Field IDs (discovered at runtime) ids = { @@ -36,7 +74,16 @@ VTX = { send = nil, }, - -- Current VTX state (parsed from folder name) + -- Band lookup tables. BAND_LETTERS is 1-based: band 0 has no letter, and its label + -- differs by context ("Off" for a disabled VTX, "--" for an unused 6POS preset slot). + BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, + BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, + + -- Status line shown by the UI until discovery finishes + statusText = "Initializing...", + + -- Current VTX state (parsed from folder name). Stable table identity: + -- mutated in place, never replaced -- UI closures capture a reference. state = { band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L bandLetter = "?", @@ -46,7 +93,8 @@ VTX = { pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch }, - -- Desired VTX state (edited by user in full-screen UI) + -- Desired VTX state (edited by user in full-screen UI). Same stable + -- identity contract as state. desired = { band = 5, -- Raceband channel = 1, @@ -55,14 +103,14 @@ VTX = { }, } ---- Parse "VTX Admin (R:4:2:P)" into VTX.state fields. +--- Parse "VTX Admin (R:4:2:P)" into VTXAdmin.state fields. -- ExpressLRS writes "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])": band Off drops the whole -- suffix, power "-" drops both power and pit mode, pit mode Off drops itself. PITMODE is "P" -- when set to On, or the aux label ("AUX1\192".."AUX10\193", \192/\193 = up/down arrow) when -- bound to a switch. The name carries only the binding, never the switch position, so an aux -- binding sets pitmodeAux and leaves pitmode false. -function VTX.parseFolderName(name) - local s = VTX.state +local function parseFolderName(name) + local s = VTXAdmin.state local content = string.match(name, "%((.+)%)") if not content then s.band = 0 @@ -83,7 +131,7 @@ function VTX.parseFolderName(name) end s.bandLetter = parts[1] - s.band = VTX.BAND_VALUES[parts[1]] or 0 + s.band = VTXAdmin.BAND_VALUES[parts[1]] or 0 s.channel = tonumber(parts[2]) or 0 s.power = tonumber(parts[3]) or 0 if #parts < 4 then @@ -110,26 +158,26 @@ function VTX.parseFolderName(name) end --- True when the VTX is tuned to a band. -function VTX.isTuned() - return Protocol.isActive() and VTX.state.band > 0 +function VTXAdmin.isTuned() + return VTXAdmin.isActive() and VTXAdmin.state.band > 0 end --- True when the module is up but the VTX band is set to Off. -function VTX.isDisabled() - return Protocol.isActive() and VTX.state.band == 0 +function VTXAdmin.isDisabled() + return VTXAdmin.isActive() and VTXAdmin.state.band == 0 end --- True when a power level is set. ExpressLRS omits power and pit mode from the VTX Admin --- folder name when power is "-", and hides the Pitmode field entirely, so neither value is --- meaningful until a power level is chosen. -function VTX.hasPower() - return VTX.isTuned() and VTX.state.power > 0 +function VTXAdmin.hasPower() + return VTXAdmin.isTuned() and VTXAdmin.state.power > 0 end --- Sync desired values with current state (e.g. on discovery or entering full-screen). -function VTX.syncDesiredFromState() - local s = VTX.state - local d = VTX.desired +function VTXAdmin.syncDesiredFromState() + local s = VTXAdmin.state + local d = VTXAdmin.desired d.band = s.band d.channel = s.channel d.power = s.power @@ -137,103 +185,83 @@ function VTX.syncDesiredFromState() end -- ============================================================================ --- Protocol Module: discovery state machine and write policy over a session +-- VTXAdmin: state machine query helpers -- ============================================================================ --- Every widget instance owns a CRSF parameter session in passive fan-out --- mode: any field from the TX module is accepted, so sibling instances see --- every response they did not request themselves. -local session - -Protocol = { - -- State machine constants - STATE_INIT = 0, - STATE_NO_MODULE = 1, - STATE_DISCOVER_ROOT = 2, - STATE_DISCOVER_CHILDREN = 3, - STATE_DISCOVER_VTX = 4, - STATE_READY = 5, - STATE_SENDING = 6, - - -- Current state - state = 0, -- STATE_INIT - statusText = "Initializing...", - - -- Previous tick timestamp, to detect suspension: a standalone tool pauses - -- widget scripts, and whatever it changed needs one read-back on resume. - lastTick = 0, -} - --- State query helpers -function Protocol.isReady() - return Protocol.state == Protocol.STATE_READY +function VTXAdmin.isReady() + return VTXAdmin.phase == VTXAdmin.PHASE_READY end -function Protocol.isSending() - return Protocol.state == Protocol.STATE_SENDING +function VTXAdmin.isSending() + return VTXAdmin.phase == VTXAdmin.PHASE_SENDING end -function Protocol.isActive() - return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING +function VTXAdmin.isActive() + return VTXAdmin.phase == VTXAdmin.PHASE_READY or VTXAdmin.phase == VTXAdmin.PHASE_SENDING end --- True when a CRSF module answered discovery. Weaker than isActive(): the 6POS preset --- cheatsheet is local radio state read from presets.txt, not VTX telemetry, so it is worth --- showing before discovery finishes. -function Protocol.hasModule() - return Protocol.state ~= Protocol.STATE_NO_MODULE +function VTXAdmin.hasModule() + return VTXAdmin.phase ~= VTXAdmin.PHASE_NO_MODULE end -- ============================================================================ --- Protocol: field handler (session onFieldUpdate callback) +-- VTXAdmin: field handler (session onFieldUpdate callback) -- ============================================================================ -function Protocol.onField(field) +local function onField(field) local fieldId = field.id local fieldName = field.name - local st = Protocol.state - if st == Protocol.STATE_DISCOVER_ROOT then + if VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_ROOT then if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then -- The session auto-queues the root children off this entry - Protocol.state = Protocol.STATE_DISCOVER_CHILDREN - Protocol.statusText = "Discovering fields..." + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_CHILDREN + VTXAdmin.statusText = "Discovering fields..." end - elseif st == Protocol.STATE_DISCOVER_CHILDREN then + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_CHILDREN then if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then - VTX.ids.folder = fieldId - VTX.parseFolderName(fieldName) + VTXAdmin.ids.folder = fieldId + parseFolderName(fieldName) session:loadFolder(fieldId) - Protocol.state = Protocol.STATE_DISCOVER_VTX - Protocol.statusText = "Loading VTX fields..." - elseif not session:isLoading() and VTX.ids.folder == nil then - Protocol.statusText = "VTX Admin not found" + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_VTX + VTXAdmin.statusText = "Loading VTX fields..." + elseif not session:isLoading() and VTXAdmin.ids.folder == nil then + VTXAdmin.statusText = "VTX Admin not found" end - elseif st == Protocol.STATE_DISCOVER_VTX then + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_VTX then if fieldName == "Band" or fieldName == "Band/Enable" then - VTX.ids.band = fieldId + VTXAdmin.ids.band = fieldId elseif fieldName == "Channel" then - VTX.ids.channel = fieldId + VTXAdmin.ids.channel = fieldId elseif fieldName == "Pwr Lvl" then - VTX.ids.power = fieldId + VTXAdmin.ids.power = fieldId elseif fieldName == "Pitmode" then - VTX.ids.pitmode = fieldId + VTXAdmin.ids.pitmode = fieldId elseif fieldName == "Send VTx" then - VTX.ids.send = fieldId + VTXAdmin.ids.send = fieldId end if not session:isLoading() then - if VTX.ids.band and VTX.ids.channel and VTX.ids.power and VTX.ids.pitmode and VTX.ids.send then - Protocol.state = Protocol.STATE_READY - Protocol.statusText = "" - VTX.syncDesiredFromState() + if + VTXAdmin.ids.band + and VTXAdmin.ids.channel + and VTXAdmin.ids.power + and VTXAdmin.ids.pitmode + and VTXAdmin.ids.send + then + VTXAdmin.phase = VTXAdmin.PHASE_READY + VTXAdmin.statusText = "" + VTXAdmin.syncDesiredFromState() else - Protocol.statusText = "VTX fields incomplete" + VTXAdmin.statusText = "VTX fields incomplete" end end - elseif st == Protocol.STATE_READY then - if fieldId == VTX.ids.folder then - VTX.parseFolderName(fieldName) + elseif VTXAdmin.phase == VTXAdmin.PHASE_READY then + if fieldId == VTXAdmin.ids.folder then + parseFolderName(fieldName) end end end @@ -241,271 +269,13 @@ end session = CRSFSession.new({ acceptUnsolicited = true, responseTimeout = 50, -- always the local TX module - onFieldUpdate = Protocol.onField, + onFieldUpdate = onField, }) -- Register on the shared CRSF singleton's fan-out session:attachBus() -- ============================================================================ --- Protocol: State machine tick --- ============================================================================ - -function Protocol.tick() - local now = getTime() - - -- A tick gap over a second means the widget was suspended — a standalone - -- tool had the screen and may have changed the module config — so read the - -- folder back once on resume. The bounded refresh slot retries a lost - -- frame without ever polling: every push replaces one RC-channels frame - -- on the handset->module UART, so the folder is only read when something - -- can have changed it. - if Protocol.lastTick > 0 and now - Protocol.lastTick > 100 and Protocol.state == Protocol.STATE_READY then - session:refreshField(VTX.ids.folder, 0, 3) - end - Protocol.lastTick = now - - local st = Protocol.state - - if st == Protocol.STATE_INIT then - if crsf.hasCrsfModule() then - Protocol.state = Protocol.STATE_DISCOVER_ROOT - Protocol.statusText = "Discovering..." - session:reloadAll() - else - Protocol.state = Protocol.STATE_NO_MODULE - Protocol.statusText = "No CRSF module" - end - elseif st == Protocol.STATE_SENDING and not session:isWriting() then - print("VTXAdmin: write queue drained") - Protocol.state = Protocol.STATE_READY - -- Read the folder back ~100ms after the last write so the module has - -- applied the change; the simulator defers folder-name updates ~20ms. - session:refreshField(VTX.ids.folder, 10, 3) - end - - session:tick() -end - --- ============================================================================ --- Protocol: Write queue builder --- ============================================================================ - ---- Write changed config fields (band, channel, power, pitmode) to the ELRS module. ---- Does NOT send the "Send VTx" command — call pushToVtx() separately for that. -function Protocol.writeConfig() - if not Protocol.isReady() then - print("VTXAdmin: writeConfig() skipped - not ready") - return - end - - local s = VTX.state - local d = VTX.desired - - print(table.concat({ - "VTXAdmin: writeConfig() desired: band=", - d.band, - " ch=", - d.channel, - " pwr=", - d.power, - " pit=", - tostring(d.pitmode), - })) - print(table.concat({ - "VTXAdmin: writeConfig() current: band=", - s.band, - " ch=", - s.channel, - " pwr=", - s.power, - " pit=", - tostring(s.pitmode), - })) - - local wrote = 0 - if d.band ~= s.band then - session:writeField({ id = VTX.ids.band, value = d.band }) - wrote = wrote + 1 - end - if d.channel ~= s.channel then - session:writeField({ id = VTX.ids.channel, value = d.channel }) - wrote = wrote + 1 - end - if d.power ~= s.power then - session:writeField({ id = VTX.ids.power, value = d.power }) - wrote = wrote + 1 - end - - local desiredPit = d.pitmode - local currentPit = s.pitmode and 1 or 0 - if desiredPit ~= currentPit then - session:writeField({ id = VTX.ids.pitmode, value = desiredPit }) - wrote = wrote + 1 - end - - print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) - - if wrote > 0 then - Protocol.state = Protocol.STATE_SENDING - end -end - ---- Send the "Send VTx" command, pushing config to the VTX. -function Protocol.pushToVtx() - if not Protocol.isReady() and Protocol.state ~= Protocol.STATE_SENDING then - print("VTXAdmin: pushToVtx() skipped - not ready") - return - end - - print("VTXAdmin: pushToVtx() - sending Send VTx command") - session:writeField({ id = VTX.ids.send, value = crsf.CONST.CMD_CLICK }) - Protocol.state = Protocol.STATE_SENDING -end - --- ============================================================================ --- Presets Module: 6POS preset storage, file I/O, quick-change processing --- ============================================================================ - -Presets = { - PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt", - - -- Preset data - items = {}, - enabled = false, - source = 0, -- 6POS source ID (0 = not configured) - autoPushVtx = false, -- auto push to VTX on 6POS change - pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) - - -- 6POS processing state - lastPos = -1, - stablePos = -1, - stableTime = 0, - DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) - - -- Push source edge detection state. nil until the first sample has been taken. - ---@type boolean? - pushLastHigh = nil, -} - --- ============================================================================ --- Presets: File I/O (key=value format) --- ============================================================================ - ---- Parse a "key=value" line using plain string.find (no regex). ---- Returns key, value strings or nil if no '=' found. -local function parseKV(line) - local eq = string.find(line, "=", 1, true) - if not eq then - return nil, nil - end - return string.sub(line, 1, eq - 1), string.sub(line, eq + 1) -end - ---- Split "band,channel" using plain string.find (no regex). -local function splitBandChannel(val) - local comma = string.find(val, ",", 1, true) - if not comma then - return nil, nil - end - return tonumber(string.sub(val, 1, comma - 1)), tonumber(string.sub(val, comma + 1)) -end - ---- File format: key=value lines (one per line). ---- Keys: enabled, source, autoPushVtx, pushSource, p1..p6 (values: "band,channel"). -function Presets.load() - local p = {} - local enabled = false - local source = 0 - local autoPushVtx = false - local pushSource = 0 - local f = io.open(Presets.PATH, "r") - if f then - local data = io.read(f, 512) - io.close(f) - if data and #data > 0 then - -- Split by newlines using plain string.find - local pos = 1 - while pos <= #data do - local nl = string.find(data, "\n", pos, true) - local line - if nl then - line = string.sub(data, pos, nl - 1) - pos = nl + 1 - else - line = string.sub(data, pos) - pos = #data + 1 - end - local key, val = parseKV(line) - if key == "enabled" then - enabled = (val == "1") - elseif key == "source" then - source = tonumber(val) or 0 - elseif key == "autoPushVtx" then - autoPushVtx = (val == "1") - elseif key == "pushSource" then - pushSource = tonumber(val) or 0 - elseif key and val then - -- Check for p1..p6 using plain sub - if string.sub(key, 1, 1) == "p" then - local idx = tonumber(string.sub(key, 2)) - if idx and idx >= 1 and idx <= 6 then - local b, ch = splitBandChannel(val) - if b and ch then - p[idx] = { band = b, channel = ch } - end - end - end - end - end - end - end - -- Fill missing positions with Raceband defaults (R1..R6) - for i = 1, 6 do - if not p[i] then - p[i] = { band = 5, channel = i } - end - end - Presets.items = p - Presets.enabled = enabled - Presets.source = source - Presets.autoPushVtx = autoPushVtx - Presets.pushSource = pushSource - - print(table.concat({ - "VTXAdmin: presets loaded - enabled=", - tostring(enabled), - " source=", - source, - " autoPushVtx=", - tostring(autoPushVtx), - " pushSource=", - pushSource, - })) - for i = 1, 6 do - print(table.concat({ "VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel })) - end -end - -function Presets.save() - print(table.concat({ "VTXAdmin: saving presets to ", Presets.PATH })) - local f = io.open(Presets.PATH, "w") - if f then - io.write(f, table.concat({ "enabled=", Presets.enabled and "1" or "0", "\n" })) - io.write(f, table.concat({ "source=", Presets.source, "\n" })) - io.write(f, table.concat({ "autoPushVtx=", Presets.autoPushVtx and "1" or "0", "\n" })) - io.write(f, table.concat({ "pushSource=", Presets.pushSource, "\n" })) - for i = 1, 6 do - io.write(f, table.concat({ "p", i, "=", Presets.items[i].band, ",", Presets.items[i].channel, "\n" })) - end - io.close(f) - print("VTXAdmin: presets saved OK") - else - print(table.concat({ "VTXAdmin: ERROR - could not open ", Presets.PATH, " for writing" })) - end -end - --- ============================================================================ --- Presets: 6POS quick-change processing +-- VTXAdmin: 6POS quick-change and push-trigger automation -- ============================================================================ local function mapTo6Pos(value) @@ -519,17 +289,17 @@ local function mapTo6Pos(value) return pos end ---- Called every background tick. Reads the 6POS source, debounces, ---- and triggers a VTX send on edge-detected position changes. -function Presets.process() - if not Presets.enabled then +--- Runs every tick. Reads the 6POS source, debounces, and applies the +--- matching preset on edge-detected position changes. +local function process6Pos() + if not PresetsStorage.enabled then return end - if Presets.source == 0 then + if PresetsStorage.source == 0 then return end - local value = getValue(Presets.source) + local value = getValue(PresetsStorage.source) if value == nil then return end @@ -538,66 +308,60 @@ function Presets.process() local now = getTime() -- Debounce: require stable position for DEBOUNCE ticks - if pos ~= Presets.stablePos then - Presets.stablePos = pos - Presets.stableTime = now + if pos ~= VTXAdmin.stablePos then + VTXAdmin.stablePos = pos + VTXAdmin.stableTime = now return end - if now - Presets.stableTime < Presets.DEBOUNCE then + if now - VTXAdmin.stableTime < VTXAdmin.DEBOUNCE then return end -- Only consume a position once a write can actually land. writeConfig() drops - -- everything outside STATE_READY, and lastPos is latched before it is called, so + -- everything outside the ready phase, and lastPos is latched before it is called, so -- latching any earlier discards the position permanently -- during a send, and during -- discovery whenever it outruns the debounce. Holding until ready is also what makes -- the first tick after discovery assert the boot position to the module. - if not Protocol.isReady() then + if not VTXAdmin.isReady() then return end -- Edge-triggered: only send on position change - if pos == Presets.lastPos then + if pos == VTXAdmin.lastPos then return end - Presets.lastPos = pos + VTXAdmin.lastPos = pos - local preset = Presets.items[pos] + local preset = PresetsStorage.items[pos] if preset and preset.band > 0 then - -- Re-base on the module's current state so a 6POS change only ever writes band and - -- channel. VTX.desired can hold stale power/pitmode: ExpressLRS omits both from the - -- folder name when power is "-", and nothing re-syncs after a send completes. - VTX.syncDesiredFromState() - VTX.desired.band = preset.band - VTX.desired.channel = preset.channel print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel })) - Protocol.writeConfig() - if Presets.autoPushVtx then - Protocol.pushToVtx() + VTXAdmin.applyPreset(preset.band, preset.channel) + if PresetsStorage.autoPushVtx then + VTXAdmin.pushToVtx() end else print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> Off (skipped)" })) end end ---- Called every background tick. Edge-detects the pushSource going high ---- and triggers Protocol.pushToVtx() to send the current config to the VTX. -function Presets.processPushSource() - if Presets.autoPushVtx then +--- Runs every tick. Edge-detects the pushSource going high and triggers +--- pushToVtx() to send the current config to the VTX. +local function processPushTrigger() + if PresetsStorage.autoPushVtx then return end - if Presets.pushSource == 0 then + if PresetsStorage.pushSource == 0 then return end - local val = getValue(Presets.pushSource) + local val = getValue(PresetsStorage.pushSource) if val == nil then return end local high = val > 0 - local wasHigh = Presets.pushLastHigh - Presets.pushLastHigh = high + local wasHigh = VTXAdmin.pushLastHigh + VTXAdmin.pushLastHigh = high if wasHigh == nil then -- First sample after create or reassignment: adopt the level without firing. A @@ -608,248 +372,146 @@ function Presets.processPushSource() -- Edge detection: trigger only on rising edge (low -> high) if high and not wasHigh then print("VTXAdmin: push source triggered - sending VTx command") - Protocol.pushToVtx() + VTXAdmin.pushToVtx() end end --- Initialize presets from file -Presets.load() - -- ============================================================================ --- WidgetLayout: minimized zone container builders +-- VTXAdmin: state machine tick -- ============================================================================ -local WidgetLayout = {} - -function WidgetLayout.column(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = 0, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end - -function WidgetLayout.row(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end +function VTXAdmin.tick() + local now = getTime() --- ============================================================================ --- VTXDisplay: shared display formatters for minimized UI --- ============================================================================ + -- A tick gap over a second means the widget was suspended — a standalone + -- tool had the screen and may have changed the module config — so read the + -- folder back once on resume. The bounded refresh slot retries a lost + -- frame without ever polling: every push replaces one RC-channels frame + -- on the handset->module UART, so the folder is only read when something + -- can have changed it. + if VTXAdmin.lastTick > 0 and now - VTXAdmin.lastTick > 100 and VTXAdmin.phase == VTXAdmin.PHASE_READY then + session:refreshField(VTXAdmin.ids.folder, 0, 3) + end + VTXAdmin.lastTick = now -local VTXDisplay = {} + if VTXAdmin.phase == VTXAdmin.PHASE_INIT then + if crsf.hasCrsfModule() then + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_ROOT + VTXAdmin.statusText = "Discovering..." + session:reloadAll() + else + VTXAdmin.phase = VTXAdmin.PHASE_NO_MODULE + VTXAdmin.statusText = "No CRSF module" + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_SENDING and not session:isWriting() then + print("VTXAdmin: write queue drained") + VTXAdmin.phase = VTXAdmin.PHASE_READY + -- Read the folder back ~100ms after the last write so the module has + -- applied the change; the simulator defers folder-name updates ~20ms. + session:refreshField(VTXAdmin.ids.folder, 10, 3) + end ---- True when VTX is tuned to a band (band+channel should be shown in fixed column). -function VTXDisplay.showChannel() - return VTX.isTuned() -end + session:tick() ---- True when a status message should be shown (loading, error, VTX off). -function VTXDisplay.showStatus() - return not VTX.isTuned() + -- After the session pump, matching the write-queue timing the automation + -- had as separate background() calls: writes it queues go out next tick. + process6Pos() + processPushTrigger() end ---- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise. -function VTXDisplay.bandChannel() - if not VTX.isTuned() then - return "" - end - return table.concat({ VTX.state.bandLetter, VTX.state.channel }) -end +-- ============================================================================ +-- VTXAdmin: write queue builder +-- ============================================================================ ---- Short status message for non-VTX states, "" when VTX is tuned. -function VTXDisplay.statusText() - if not Protocol.hasModule() then - return "No module" - end - if not Protocol.isActive() then - return "Loading..." - end - if VTX.state.band == 0 then - return "VTX Off" +--- Write changed config fields (band, channel, power, pitmode) to the ELRS module. +--- Does NOT send the "Send VTx" command — call pushToVtx() separately for that. +function VTXAdmin.writeConfig() + if not VTXAdmin.isReady() then + print("VTXAdmin: writeConfig() skipped - not ready") + return end - return "" -end -function VTXDisplay.powerShort() - if not VTX.hasPower() then - return "" - end - return table.concat({ "P", VTX.state.power }) -end + local s = VTXAdmin.state + local d = VTXAdmin.desired -function VTXDisplay.powerLong() - if not VTX.hasPower() then - return "" - end - return table.concat({ "Power ", VTX.state.power }) -end + print(table.concat({ + "VTXAdmin: writeConfig() desired: band=", + d.band, + " ch=", + d.channel, + " pwr=", + d.power, + " pit=", + tostring(d.pitmode), + })) + print(table.concat({ + "VTXAdmin: writeConfig() current: band=", + s.band, + " ch=", + s.channel, + " pwr=", + s.power, + " pit=", + tostring(s.pitmode), + })) ---- Pit mode state. "" when no power is set: ExpressLRS cannot send pit mode without it. ---- A switch binding names the switch rather than asserting a position the folder name ---- does not carry. -function VTXDisplay.pitText() - if not VTX.hasPower() then - return "" + local wrote = 0 + if d.band ~= s.band then + session:writeField({ id = VTXAdmin.ids.band, value = d.band }) + wrote = wrote + 1 end - if VTX.state.pitmode then - return "Pit Mode On" + if d.channel ~= s.channel then + session:writeField({ id = VTXAdmin.ids.channel, value = d.channel }) + wrote = wrote + 1 end - if VTX.state.pitmodeAux then - return table.concat({ "Pit Mode ", VTX.state.pitmodeAux }) + if d.power ~= s.power then + session:writeField({ id = VTXAdmin.ids.power, value = d.power }) + wrote = wrote + 1 end - return "Pit Mode Off" -end ---- As pitText, but reports a disabled VTX instead of falling silent. -function VTXDisplay.pitTextLong() - if VTX.isDisabled() then - return "VTX Disabled" + local desiredPit = d.pitmode + local currentPit = s.pitmode and 1 or 0 + if desiredPit ~= currentPit then + session:writeField({ id = VTXAdmin.ids.pitmode, value = desiredPit }) + wrote = wrote + 1 end - return VTXDisplay.pitText() -end - ---- Terse flag for narrow tiers. Only a confirmed pit mode is worth the width. -function VTXDisplay.pitShort() - return VTX.state.pitmode and "Pit" or "" -end - ---- Red only when pit mode is confirmed on. An aux binding is not an assertion. -function VTXDisplay.pitColor() - return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 -end -function VTXDisplay.detailLine() - if not VTX.hasPower() then - return "" - end - return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() }) -end + print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) -function VTXDisplay.detailLong() - if VTX.isDisabled() then - return "VTX Disabled" - end - if not VTX.hasPower() then - return "" + if wrote > 0 then + VTXAdmin.phase = VTXAdmin.PHASE_SENDING end - return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) end -function VTXDisplay.mainColor() - if VTX.state.pitmode then - return RED - end - return COLOR_THEME_PRIMARY1 +--- Write a preset's band and channel on top of the module's current state. +--- Re-basing on state means a preset only ever writes band and channel: +--- desired can hold stale power/pitmode -- ExpressLRS omits both from the +--- folder name when power is "-", and nothing re-syncs after a send completes. +function VTXAdmin.applyPreset(band, channel) + VTXAdmin.syncDesiredFromState() + VTXAdmin.desired.band = band + VTXAdmin.desired.channel = channel + VTXAdmin.writeConfig() end -function VTXDisplay.build6posLabels(font) - if not Protocol.hasModule() then - return {} - end - if not Presets.enabled then - return {} - end - local labels = {} - for i = 1, 6 do - local idx = i - labels[#labels + 1] = { - type = lvgl.LABEL, - font = font or SMLSIZE, - color = function() - return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED - end, - text = function() - local p = Presets.items[idx] - if p.band == 0 then - return table.concat({ idx, ":--" }) - end - return table.concat({ idx, ":", VTX.BAND_LETTERS[p.band] or "?", p.channel }) - end, - } +--- Send the "Send VTx" command, pushing config to the VTX. +function VTXAdmin.pushToVtx() + if not VTXAdmin.isReady() and VTXAdmin.phase ~= VTXAdmin.PHASE_SENDING then + print("VTXAdmin: pushToVtx() skipped - not ready") + return end - return labels -end ---- Wrap 6POS labels in a row box. Shared by the one-row and two-row cheatsheets. -local function cheatsheetRow(labels) - return { - type = lvgl.BOX, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - align = LEFT, - visible = Protocol.hasModule, - children = labels, - } + print("VTXAdmin: pushToVtx() - sending Send VTx command") + session:writeField({ id = VTXAdmin.ids.send, value = crsf.CONST.CMD_CLICK }) + VTXAdmin.phase = VTXAdmin.PHASE_SENDING end ---- Single row of all six presets, for tiers with only one line to spare. -function VTXDisplay.buildCheatsheet(font) - local labels = VTXDisplay.build6posLabels(font) - if #labels == 0 then - return nil - end - return cheatsheetRow(labels) -end +-- ============================================================================ +-- Display components +-- ============================================================================ ---- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows. -function VTXDisplay.buildCheatsheetRows(font) - local labels = VTXDisplay.build6posLabels(font) - if #labels == 0 then - return nil, nil - end - local row1, row2 = {}, {} - for i = 1, 3 do - row1[#row1 + 1] = labels[i] - end - for i = 4, 6 do - row2[#row2 + 1] = labels[i] - end - return cheatsheetRow(row1), cheatsheetRow(row2) -end +local VTXDisplay, WidgetLayout = loadScript("/WIDGETS/ELRSVTXAdmin/ui/display.lua")(VTXAdmin, PresetsStorage) +local FullScreenUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua")(VTXAdmin, PresetsStorage) -- ============================================================================ -- Screen detection and UI loading @@ -880,320 +542,12 @@ end local screenId = getScreenId() local uiPath = table.concat({ "/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ - VTX = VTX, + VTXAdmin = VTXAdmin, bgOpacity = bgOpacity, VTXDisplay = VTXDisplay, WidgetLayout = WidgetLayout, }) --- ============================================================================ --- Full-screen row helpers (shared across all screen sizes) --- ============================================================================ - --- Portrait screens get a narrower label column to leave more room for controls. -local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 - -local function createRow(container, label, hint, visibleFn) - local row = container:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - visible = visibleFn, - }) - - local labelChildren = { - { type = lvgl.LABEL, y = lvgl.PAD_SMALL, text = label, color = COLOR_THEME_PRIMARY1 }, - } - if hint then - labelChildren[#labelChildren + 1] = { - type = lvgl.LABEL, - text = hint, - color = COLOR_THEME_DISABLED, - font = SMLSIZE, - w = lvgl.PERCENT_SIZE + 100, - } - end - - row:rectangle({ - w = lvgl.PERCENT_SIZE + LABEL_PCT, - thickness = 0, - flexFlow = hint and lvgl.FLOW_COLUMN or nil, - h = not hint and lvgl.UI_ELEMENT_HEIGHT or nil, - children = labelChildren, - }) - - local ctrl = row:rectangle({ - w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - align = LEFT + VCENTER, - }) - - return ctrl -end - -local function createChoiceRow(container, label, values, getFn, setFn) - local ctrl = createRow(container, label) - ctrl:choice({ - title = label, - values = values, - get = getFn, - set = setFn, - }) -end - -local function createNumberRow(container, label, min, max, getFn, setFn, editedFn, displayFn) - local ctrl = createRow(container, label) - ctrl:numberEdit({ - min = min, - max = max, - get = getFn, - set = setFn, - edited = editedFn, - display = displayFn, - }) -end - -local function createToggleRow(container, label, getFn, setFn, visibleFn, hint) - local ctrl = createRow(container, label, hint, visibleFn) - ctrl:toggle({ - get = getFn, - set = setFn, - }) -end - -local function createSourceRow(container, label, getFn, setFn, filter, hint) - local ctrl = createRow(container, label, hint) - ctrl:source({ - get = getFn, - set = setFn, - filter = filter, - }) -end - -local function createHintRow(container, text) - container:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - children = { - { - type = lvgl.LABEL, - text = text, - color = COLOR_THEME_DISABLED, - font = SMLSIZE, - w = lvgl.PERCENT_SIZE + 100, - }, - }, - }) -end - -local function createSectionHeader(container, title) - container:build({ - { - type = lvgl.RECTANGLE, - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_SMALL, - thickness = 0, - }, - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_PRIMARY1, - text = title, - }, - }) -end - --- ============================================================================ --- Full-screen LVGL layout (shared across all screen sizes) --- ============================================================================ - -local function buildFullScreen() - lvgl.clear() - - local d = VTX.desired - - local pg = lvgl.page({ - title = "ExpressLRS", - subtitle = function() - if Protocol.isActive() then - return "VTX Administrator" - end - return Protocol.statusText - end, - back = function() - lvgl.exitFullScreen() - end, - }) - - -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) - if not Protocol.hasModule() then - pg:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, - children = { - { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, - { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, - { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, - { - type = lvgl.LABEL, - text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", - color = COLOR_THEME_DISABLED, - }, - }, - }) - return - end - - local fields = pg:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_COLUMN, - }) - - -- VTX Settings section - createSectionHeader(fields, "VTX Settings") - - createChoiceRow(fields, "Band", { "Off", "A", "B", "E", "F", "R", "L" }, function() - return d.band + 1 - end, function(idx) - d.band = idx - 1 - Protocol.writeConfig() - end) - - createNumberRow(fields, "Channel", 1, 8, function() - return d.channel - end, function(v) - d.channel = v - end, function(v) - d.channel = v - Protocol.writeConfig() - end) - - createNumberRow(fields, "Power Level", 0, 8, function() - return d.power - end, function(v) - d.power = v - end, function(v) - d.power = v - Protocol.writeConfig() - end, function(v) - return v == 0 and "-" or tostring(v) - end) - - -- Pit mode rides on the power byte, so ExpressLRS hides it while power is "-". - createToggleRow(fields, "Pit Mode", function() - return d.pitmode - end, function(v) - d.pitmode = v - Protocol.writeConfig() - end, function() - return d.power > 0 - end) - - local sendWrapper = fields:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_COLUMN, - align = CENTER, - borderPad = { top = lvgl.PAD_SMALL, bottom = lvgl.PAD_SMALL }, - }) - sendWrapper:button({ - text = function() - if Protocol.isSending() then - return "Sending..." - end - return "Send VTx" - end, - w = lvgl.PERCENT_SIZE + 99, - press = function() - Protocol.writeConfig() - Protocol.pushToVtx() - end, - active = function() - return Protocol.isReady() - end, - }) - - -- 6POS Quick Change section - createSectionHeader(fields, "6POS Quick Change") - - createToggleRow(fields, "Enabled", function() - return Presets.enabled and 1 or 0 - end, function(v) - Presets.enabled = (v == 1) - Presets.save() - end) - - createSourceRow(fields, "Source", function() - return Presets.source - end, function(v) - Presets.source = v or 0 - Presets.save() - end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) - - createToggleRow(fields, "Auto Push to VTX", function() - return Presets.autoPushVtx and 1 or 0 - end, function(v) - Presets.autoPushVtx = (v == 1) - Presets.save() - end, nil, "Send to the VTX as soon as the 6POS position changes. When off, use the trigger below.") - - createSourceRow( - fields, - "Send VTx Trigger", - function() - return Presets.pushSource - end, - function(v) - Presets.pushSource = v or 0 - Presets.pushLastHigh = nil - Presets.save() - end, - lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, - "Assign a switch or button to manually push the current VTX config to the receiver." - ) - - -- Presets section - createSectionHeader(fields, "Presets") - - createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") - - local bandValues = { "--", "A", "B", "E", "F", "R", "L" } - for i = 1, 6 do - local idx = i - local ctrl = createRow(fields, table.concat({ "Preset ", idx })) - - ctrl:choice({ - values = bandValues, - get = function() - return Presets.items[idx].band + 1 - end, - set = function(v) - Presets.items[idx].band = v - 1 - Presets.save() - end, - }) - - ctrl:numberEdit({ - min = 1, - max = 8, - get = function() - return Presets.items[idx].channel - end, - set = function(v) - Presets.items[idx].channel = v - Presets.save() - end, - visible = function() - return Presets.items[idx].band > 0 - end, - }) - end -end - -- ============================================================================ -- Widget lifecycle -- ============================================================================ @@ -1207,27 +561,25 @@ local wgt = { function wgt.background() crsf:poll() - Protocol.tick() - Presets.process() - Presets.processPushSource() + VTXAdmin.tick() end function wgt.refresh(_event, _touchState) wgt.background() - if lvgl.isFullScreen() and Presets.lastPos ~= lastBuilt6pos then - lastBuilt6pos = Presets.lastPos - buildFullScreen() + if lvgl.isFullScreen() and VTXAdmin.lastPos ~= lastBuilt6pos then + lastBuilt6pos = VTXAdmin.lastPos + FullScreenUI.build() end end function wgt.update(newOptions) wgt.options = newOptions if lvgl.isFullScreen() then - if Protocol.isReady() then - VTX.syncDesiredFromState() + if VTXAdmin.isReady() then + VTXAdmin.syncDesiredFromState() end - buildFullScreen() - lastBuilt6pos = Presets.lastPos + FullScreenUI.build() + lastBuilt6pos = VTXAdmin.lastPos else lastBuilt6pos = -1 WidgetUI.build(wgt.zone, wgt.options) diff --git a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua new file mode 100644 index 0000000..a75d884 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua @@ -0,0 +1,101 @@ +--------------------------------------------------------------------------- +-- 6POS Preset Storage -- +-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- +-- (FileStorage); returns the PresetsStorage table. -- +-- -- +-- Pure settings store: the preset slots, sources and flags, and their -- +-- persistence as a key=value file. The automation that acts on them -- +-- lives in VTXAdmin. -- +--------------------------------------------------------------------------- + +local FileStorage = ... + +-- Where the settings live on the SD card +local PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt" + +-- The file layout, declared once: FileStorage writes these keys in this order. +local SAVE_KEYS = { "enabled", "source", "autoPushVtx", "pushSource", "p1", "p2", "p3", "p4", "p5", "p6" } + +local PresetsStorage = { + items = {}, + enabled = false, + source = 0, -- 6POS source ID (0 = not configured) + autoPushVtx = false, -- auto push to VTX on 6POS change + pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) +} + +--- Split "band,channel" using plain string.find (no regex). +local function splitBandChannel(val) + local comma = string.find(val, ",", 1, true) + if not comma then + return nil, nil + end + return tonumber(string.sub(val, 1, comma - 1)), tonumber(string.sub(val, comma + 1)) +end + +--- Schema: enabled/autoPushVtx are "1"/"0" booleans, source/pushSource are +--- source IDs, p1..p6 are "band,channel" pairs defaulting to Raceband R1..R6. +function PresetsStorage.load() + local kv = FileStorage.read(PATH) or {} + local enabled = (kv.enabled == "1") + local source = tonumber(kv.source) or 0 + local autoPushVtx = (kv.autoPushVtx == "1") + local pushSource = tonumber(kv.pushSource) or 0 + local p = {} + for i = 1, 6 do + local val = kv[table.concat({ "p", i })] + if val then + local b, ch = splitBandChannel(val) + if b and ch then + p[i] = { band = b, channel = ch } + end + end + -- Fill missing positions with Raceband defaults (R1..R6) + if not p[i] then + p[i] = { band = 5, channel = i } + end + end + PresetsStorage.items = p + PresetsStorage.enabled = enabled + PresetsStorage.source = source + PresetsStorage.autoPushVtx = autoPushVtx + PresetsStorage.pushSource = pushSource + + print(table.concat({ + "VTXAdmin: presets loaded - enabled=", + tostring(enabled), + " source=", + source, + " autoPushVtx=", + tostring(autoPushVtx), + " pushSource=", + pushSource, + })) + for i = 1, 6 do + print(table.concat({ "VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel })) + end +end + +function PresetsStorage.save() + print(table.concat({ "VTXAdmin: saving presets to ", PATH })) + local values = { + enabled = PresetsStorage.enabled and "1" or "0", + source = PresetsStorage.source, + autoPushVtx = PresetsStorage.autoPushVtx and "1" or "0", + pushSource = PresetsStorage.pushSource, + } + for i = 1, 6 do + values[table.concat({ "p", i })] = + table.concat({ PresetsStorage.items[i].band, ",", PresetsStorage.items[i].channel }) + end + if FileStorage.write(PATH, SAVE_KEYS, values) then + print("VTXAdmin: presets saved OK") + else + print(table.concat({ "VTXAdmin: ERROR - could not open ", PATH, " for writing" })) + end +end + +-- Initialize presets from file +PresetsStorage.load() + +return PresetsStorage diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua new file mode 100644 index 0000000..8a09480 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -0,0 +1,249 @@ +--------------------------------------------------------------------------- +-- Minimized Display Layer -- +-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- +-- (VTXAdmin, PresetsStorage); returns VTXDisplay, WidgetLayout. -- +-- -- +-- VTXDisplay is the read-model the per-screen ui/ files consume: -- +-- zero-arg formatters passed by reference as LVGL text/color/visible -- +-- callbacks. WidgetLayout builds the minimized zone containers. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage = ... + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +-- ============================================================================ +-- VTXDisplay: shared display formatters for minimized UI +-- ============================================================================ + +local VTXDisplay = {} + +--- True when VTX is tuned to a band (band+channel should be shown in fixed column). +function VTXDisplay.showChannel() + return VTXAdmin.isTuned() +end + +--- True when a status message should be shown (loading, error, VTX off). +function VTXDisplay.showStatus() + return not VTXAdmin.isTuned() +end + +--- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise. +function VTXDisplay.bandChannel() + if not VTXAdmin.isTuned() then + return "" + end + return table.concat({ VTXAdmin.state.bandLetter, VTXAdmin.state.channel }) +end + +--- Short status message for non-VTX states, "" when VTX is tuned. +function VTXDisplay.statusText() + if not VTXAdmin.hasModule() then + return "No module" + end + if not VTXAdmin.isActive() then + return "Loading..." + end + if VTXAdmin.state.band == 0 then + return "VTX Off" + end + return "" +end + +function VTXDisplay.powerShort() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "P", VTXAdmin.state.power }) +end + +function VTXDisplay.powerLong() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "Power ", VTXAdmin.state.power }) +end + +--- Pit mode state. "" when no power is set: ExpressLRS cannot send pit mode without it. +--- A switch binding names the switch rather than asserting a position the folder name +--- does not carry. +function VTXDisplay.pitText() + if not VTXAdmin.hasPower() then + return "" + end + if VTXAdmin.state.pitmode then + return "Pit Mode On" + end + if VTXAdmin.state.pitmodeAux then + return table.concat({ "Pit Mode ", VTXAdmin.state.pitmodeAux }) + end + return "Pit Mode Off" +end + +--- As pitText, but reports a disabled VTX instead of falling silent. +function VTXDisplay.pitTextLong() + if VTXAdmin.isDisabled() then + return "VTX Disabled" + end + return VTXDisplay.pitText() +end + +--- Terse flag for narrow tiers. Only a confirmed pit mode is worth the width. +function VTXDisplay.pitShort() + return VTXAdmin.state.pitmode and "Pit" or "" +end + +--- Red only when pit mode is confirmed on. An aux binding is not an assertion. +function VTXDisplay.pitColor() + return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +function VTXDisplay.detailLine() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() }) +end + +function VTXDisplay.detailLong() + if VTXAdmin.isDisabled() then + return "VTX Disabled" + end + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) +end + +function VTXDisplay.mainColor() + if VTXAdmin.state.pitmode then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +function VTXDisplay.build6posLabels(font) + if not VTXAdmin.hasModule() then + return {} + end + if not PresetsStorage.enabled then + return {} + end + local labels = {} + for i = 1, 6 do + local idx = i + labels[#labels + 1] = { + type = lvgl.LABEL, + font = font or SMLSIZE, + color = function() + return (VTXAdmin.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED + end, + text = function() + local p = PresetsStorage.items[idx] + if p.band == 0 then + return table.concat({ idx, ":--" }) + end + return table.concat({ idx, ":", VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + end, + } + end + return labels +end + +--- Wrap 6POS labels in a row box. Shared by the one-row and two-row cheatsheets. +local function cheatsheetRow(labels) + return { + type = lvgl.BOX, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + align = LEFT, + visible = VTXAdmin.hasModule, + children = labels, + } +end + +--- Single row of all six presets, for tiers with only one line to spare. +function VTXDisplay.buildCheatsheet(font) + local labels = VTXDisplay.build6posLabels(font) + if #labels == 0 then + return nil + end + return cheatsheetRow(labels) +end + +--- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows. +function VTXDisplay.buildCheatsheetRows(font) + local labels = VTXDisplay.build6posLabels(font) + if #labels == 0 then + return nil, nil + end + local row1, row2 = {}, {} + for i = 1, 3 do + row1[#row1 + 1] = labels[i] + end + for i = 4, 6 do + row2[#row2 + 1] = labels[i] + end + return cheatsheetRow(row1), cheatsheetRow(row2) +end + +return VTXDisplay, WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua new file mode 100644 index 0000000..9a88631 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua @@ -0,0 +1,323 @@ +--------------------------------------------------------------------------- +-- Full-Screen Editor -- +-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- +-- (VTXAdmin, PresetsStorage); returns the FullScreenUI table. -- +-- -- +-- One layout for every screen size: VTX Settings editing -- +-- VTXAdmin.desired, the Send VTx button, 6POS Quick Change and preset -- +-- slot rows saving through PresetsStorage, and the no-module checklist. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage = ... + +local FullScreenUI = {} + +-- ============================================================================ +-- Row helpers (shared across all screen sizes) +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for controls. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +local function createRow(container, label, hint, visibleFn) + local row = container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + visible = visibleFn, + }) + + local labelChildren = { + { type = lvgl.LABEL, y = lvgl.PAD_SMALL, text = label, color = COLOR_THEME_PRIMARY1 }, + } + if hint then + labelChildren[#labelChildren + 1] = { + type = lvgl.LABEL, + text = hint, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + } + end + + row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + flexFlow = hint and lvgl.FLOW_COLUMN or nil, + h = not hint and lvgl.UI_ELEMENT_HEIGHT or nil, + children = labelChildren, + }) + + local ctrl = row:rectangle({ + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER, + }) + + return ctrl +end + +local function createChoiceRow(container, label, values, getFn, setFn) + local ctrl = createRow(container, label) + ctrl:choice({ + title = label, + values = values, + get = getFn, + set = setFn, + }) +end + +local function createNumberRow(container, label, min, max, getFn, setFn, editedFn, displayFn) + local ctrl = createRow(container, label) + ctrl:numberEdit({ + min = min, + max = max, + get = getFn, + set = setFn, + edited = editedFn, + display = displayFn, + }) +end + +local function createToggleRow(container, label, getFn, setFn, visibleFn, hint) + local ctrl = createRow(container, label, hint, visibleFn) + ctrl:toggle({ + get = getFn, + set = setFn, + }) +end + +local function createSourceRow(container, label, getFn, setFn, filter, hint) + local ctrl = createRow(container, label, hint) + ctrl:source({ + get = getFn, + set = setFn, + filter = filter, + }) +end + +local function createHintRow(container, text) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + children = { + { + type = lvgl.LABEL, + text = text, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, + }) +end + +-- ============================================================================ +-- Full-screen LVGL layout (shared across all screen sizes) +-- ============================================================================ + +function FullScreenUI.build() + lvgl.clear() + + local d = VTXAdmin.desired + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = function() + if VTXAdmin.isActive() then + return "VTX Administrator" + end + return VTXAdmin.statusText + end, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) + if not VTXAdmin.hasModule() then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- VTX Settings section + createSectionHeader(fields, "VTX Settings") + + createChoiceRow(fields, "Band", { "Off", "A", "B", "E", "F", "R", "L" }, function() + return d.band + 1 + end, function(idx) + d.band = idx - 1 + VTXAdmin.writeConfig() + end) + + createNumberRow(fields, "Channel", 1, 8, function() + return d.channel + end, function(v) + d.channel = v + end, function(v) + d.channel = v + VTXAdmin.writeConfig() + end) + + createNumberRow(fields, "Power Level", 0, 8, function() + return d.power + end, function(v) + d.power = v + end, function(v) + d.power = v + VTXAdmin.writeConfig() + end, function(v) + return v == 0 and "-" or tostring(v) + end) + + -- Pit mode rides on the power byte, so ExpressLRS hides it while power is "-". + createToggleRow(fields, "Pit Mode", function() + return d.pitmode + end, function(v) + d.pitmode = v + VTXAdmin.writeConfig() + end, function() + return d.power > 0 + end) + + local sendWrapper = fields:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = { top = lvgl.PAD_SMALL, bottom = lvgl.PAD_SMALL }, + }) + sendWrapper:button({ + text = function() + if VTXAdmin.isSending() then + return "Sending..." + end + return "Send VTx" + end, + w = lvgl.PERCENT_SIZE + 99, + press = function() + VTXAdmin.writeConfig() + VTXAdmin.pushToVtx() + end, + active = function() + return VTXAdmin.isReady() + end, + }) + + -- 6POS Quick Change section + createSectionHeader(fields, "6POS Quick Change") + + createToggleRow(fields, "Enabled", function() + return PresetsStorage.enabled and 1 or 0 + end, function(v) + PresetsStorage.enabled = (v == 1) + PresetsStorage.save() + end) + + createSourceRow(fields, "Source", function() + return PresetsStorage.source + end, function(v) + PresetsStorage.source = v or 0 + PresetsStorage.save() + end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) + + createToggleRow(fields, "Auto Push to VTX", function() + return PresetsStorage.autoPushVtx and 1 or 0 + end, function(v) + PresetsStorage.autoPushVtx = (v == 1) + PresetsStorage.save() + end, nil, "Send to the VTX as soon as the 6POS position changes. When off, use the trigger below.") + + createSourceRow( + fields, + "Send VTx Trigger", + function() + return PresetsStorage.pushSource + end, + function(v) + PresetsStorage.pushSource = v or 0 + VTXAdmin.pushLastHigh = nil + PresetsStorage.save() + end, + lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, + "Assign a switch or button to manually push the current VTX config to the receiver." + ) + + -- Presets section + createSectionHeader(fields, "Presets") + + createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") + + local bandValues = { "--", "A", "B", "E", "F", "R", "L" } + for i = 1, 6 do + local idx = i + local ctrl = createRow(fields, table.concat({ "Preset ", idx })) + + ctrl:choice({ + values = bandValues, + get = function() + return PresetsStorage.items[idx].band + 1 + end, + set = function(v) + PresetsStorage.items[idx].band = v - 1 + PresetsStorage.save() + end, + }) + + ctrl:numberEdit({ + min = 1, + max = 8, + get = function() + return PresetsStorage.items[idx].channel + end, + set = function(v) + PresetsStorage.items[idx].channel = v + PresetsStorage.save() + end, + visible = function() + return PresetsStorage.items[idx].band > 0 + end, + }) + end +end + +return FullScreenUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 30fec59..10fa307 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -4,7 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local VTX = ctx.VTX +local VTXAdmin = ctx.VTXAdmin local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout @@ -36,22 +36,22 @@ WidgetUI.fonts = { --- Shorter detail line for narrow portrait screen. local function detailLine() - if not VTX.hasPower() then + if not VTXAdmin.hasPower() then return "" end - local pit = VTX.state.pitmode and " Pit" or "" + local pit = VTXAdmin.state.pitmode and " Pit" or "" return table.concat({ VTXDisplay.powerShort(), pit }) end --- Shorter long-form detail line for narrow portrait screen. local function detailLong() - if VTX.isDisabled() then + if VTXAdmin.isDisabled() then return "VTX Disabled" end - if not VTX.hasPower() then + if not VTXAdmin.hasPower() then return "" end - local pit = VTX.state.pitmode and " Pit" or "" + local pit = VTXAdmin.state.pitmode and " Pit" or "" return table.concat({ VTXDisplay.powerLong(), pit }) end From 77b90352f1a2c6cbb486fae7ae0bd84c1f709a52 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 12 Aug 2026 22:15:38 +0300 Subject: [PATCH 168/218] bump version to r3 --- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index cd86415..6fab531 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -8,7 +8,7 @@ ---- # Unified tool for BW and color LCD radios (EdgeTX 2.11.6+/2.12.1+) # ---- ######################################################################### -local VERSION = "r2" +local VERSION = "r3" local useLvgl = (lvgl ~= nil) -- ============================================================================ From 9078c7a5112a41480fa2df0c92c3ba8ccfd2bc77 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 13:21:22 +0300 Subject: [PATCH 169/218] Add 6POS preset collections to VTX Admin Six collections of six band/channel presets, stored as one line per collection in presets.txt and selected from a dropdown in the full-screen editor. The dropdown drives both what the 6POS switch applies and what the preset rows below edit; picking a collection re-applies the parked switch position without touching the switch. The preset store becomes a per-radio singleton shared by every widget instance, and the 6POS and push-trigger state moves onto it: an edge -- a switch move, a collection switch, a push trigger -- is consumed by the first instance whose session can act, so it produces exactly one write per radio instead of one per instance. The push trigger re-latches when its source is reassigned, adopting the new source's level without a phantom push. The full-screen page is built once on entry and never rebuilt; values that change under it reach the screen through per-frame get() callbacks, so focus and scroll survive a collection switch. numberEdit re-polls get() only on EdgeTX 2.12.0+, so on older firmware the channel and power digits catch up when the page is next entered. --- README.md | 2 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 129 +++++++------- src/WIDGETS/ELRSVTXAdmin/main.lua | 10 +- src/WIDGETS/ELRSVTXAdmin/presets_storage.lua | 171 +++++++++++++------ src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua | 24 ++- 6 files changed, 223 insertions(+), 115 deletions(-) diff --git a/README.md b/README.md index cb62fcf..5edda75 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statisti ## VTX Administrator Widget -The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. +The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. Presets are organised into six collections selected in the widget's editor, so your home field and a race event can each keep their own set of channels. VTX Administrator Widget diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 0339fad..86bb4f2 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -6,21 +6,15 @@ -- Administrator over the CRSF config protocol (PARAMETER_READ/WRITE). -- -- Field IDs are discovered at runtime by name -- never hardcoded. -- -- -- --- Wires the components around it -- presets_storage.lua, ui/display.lua, -- --- ui/fullscreen.lua, the shared ELRS file_storage library -- and a -- --- screen-specific minimized layout from ui/ picked by LCD_W/LCD_H, -- --- then runs the widget lifecycle. Loaded fresh per widget instance, so -- --- every table here is per-instance state. -- +-- Wires the components around it -- ui/display.lua, ui/fullscreen.lua -- +-- and a screen-specific minimized layout from ui/ picked by LCD_W/LCD_H -- +-- -- then runs the widget lifecycle. Loaded fresh per widget instance, -- +-- so every table here is per-instance state, except PresetsStorage: -- +-- main.lua hands every instance the same store, whose latch table is -- +-- how the 6POS automation consumes an edge exactly once per radio. -- --------------------------------------------------------------------------- -local zone, options, crsf, CRSFSession = ... - --- ============================================================================ --- Components: file storage (shared ELRS library) and 6POS preset settings --- ============================================================================ - -local FileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua")() -local PresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua")(FileStorage) +local zone, options, crsf, CRSFSession, PresetsStorage = ... -- ============================================================================ -- VTXAdmin: client of the ELRS "VTX Administrator" service on the TX module @@ -51,19 +45,11 @@ local VTXAdmin = { -- widget scripts, and whatever it changed needs one read-back on resume. lastTick = 0, - -- 6POS processing state: last consumed position (-1 until the first settle; - -- read by the cheatsheet highlight and the full-screen rebuild tracking) - -- plus the debounce bookkeeping. - lastPos = -1, - stablePos = -1, - stableTime = 0, + -- 6POS debounce window. The processing state itself -- consumed position, + -- collection, push trigger level -- lives on PresetsStorage.latch, shared + -- across widget instances. DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) - -- Push trigger edge detection state. nil until the first sample has been - -- taken; the full-screen editor resets it on trigger-source reassignment. - ---@type boolean? - pushLastHigh = nil, - -- Field IDs (discovered at runtime) ids = { folder = nil, @@ -289,8 +275,11 @@ local function mapTo6Pos(value) return pos end ---- Runs every tick. Reads the 6POS source, debounces, and applies the ---- matching preset on edge-detected position changes. +--- Runs every tick. Reads the 6POS source, debounces, and applies the matching +--- preset when the consumed (collection, position) pair changes. The state +--- lives on the shared latch so an edge produces one write per radio, not one +--- per instance: widget callbacks run sequentially in one Lua state, so the +--- first instance that can act consumes the edge and the rest see none. local function process6Pos() if not PresetsStorage.enabled then return @@ -304,48 +293,56 @@ local function process6Pos() return end + local latch = PresetsStorage.latch local pos = mapTo6Pos(value) local now = getTime() - -- Debounce: require stable position for DEBOUNCE ticks - if pos ~= VTXAdmin.stablePos then - VTXAdmin.stablePos = pos - VTXAdmin.stableTime = now + -- Debounce: require stable position for DEBOUNCE ticks. Shared: the source + -- is radio state, so one debounce serves every instance. + if pos ~= latch.stablePos then + latch.stablePos = pos + latch.stableTime = now return end - if now - VTXAdmin.stableTime < VTXAdmin.DEBOUNCE then + if now - latch.stableTime < VTXAdmin.DEBOUNCE then return end - -- Only consume a position once a write can actually land. writeConfig() drops - -- everything outside the ready phase, and lastPos is latched before it is called, so - -- latching any earlier discards the position permanently -- during a send, and during - -- discovery whenever it outruns the debounce. Holding until ready is also what makes - -- the first tick after discovery assert the boot position to the module. + -- Only consume a position once THIS instance can write. writeConfig() drops + -- everything outside the ready phase, and the latch is taken before it is + -- called, so latching any earlier discards the edge permanently -- for every + -- instance at once. Holding until ready is also what makes the first tick + -- after discovery assert the boot position to the module. if not VTXAdmin.isReady() then return end - -- Edge-triggered: only send on position change - if pos == VTXAdmin.lastPos then + -- Edge-triggered: send when either half of the (collection, position) pair + -- the module is holding changes. Picking a different collection retunes the + -- VTX without touching the switch. + if pos == latch.lastPos and PresetsStorage.collection == latch.lastCollection then return end - VTXAdmin.lastPos = pos + latch.lastPos = pos + latch.lastCollection = PresetsStorage.collection + -- An unused slot leaves the VTX untouched rather than turning it off. The + -- latch is already taken, so this is deliberate: the skip does not retry. local preset = PresetsStorage.items[pos] - if preset and preset.band > 0 then - print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel })) - VTXAdmin.applyPreset(preset.band, preset.channel) - if PresetsStorage.autoPushVtx then - VTXAdmin.pushToVtx() - end - else - print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> Off (skipped)" })) + if not preset or preset.band == 0 then + return + end + + VTXAdmin.applyPreset(preset.band, preset.channel) + if PresetsStorage.autoPushVtx then + VTXAdmin.pushToVtx() end end --- Runs every tick. Edge-detects the pushSource going high and triggers ---- pushToVtx() to send the current config to the VTX. +--- pushToVtx() to send the current config to the VTX. The state lives on the +--- shared latch so a rising edge fires one push per radio, not one per +--- instance. local function processPushTrigger() if PresetsStorage.autoPushVtx then return @@ -359,19 +356,31 @@ local function processPushTrigger() return end + local latch = PresetsStorage.latch local high = val > 0 - local wasHigh = VTXAdmin.pushLastHigh - VTXAdmin.pushLastHigh = high - if wasHigh == nil then - -- First sample after create or reassignment: adopt the level without firing. A - -- source that is already high was not just moved there by the user. + -- The level latch is only meaningful for the source it was sampled from: a + -- reassignment adopts the new source's level without firing. A source that + -- is already high was not just moved there by the user. This also seeds the + -- latch on the first sample after boot. + if PresetsStorage.pushSource ~= latch.pushSourceSeen then + latch.pushSourceSeen = PresetsStorage.pushSource + latch.pushLastHigh = high + return + end + + -- Only consume once THIS instance can send (the same gate pushToVtx + -- applies): latching earlier would eat the rising edge for every instance + -- while nobody could act on it. + if not VTXAdmin.isReady() and not VTXAdmin.isSending() then return end + local wasHigh = latch.pushLastHigh + latch.pushLastHigh = high + -- Edge detection: trigger only on rising edge (low -> high) if high and not wasHigh then - print("VTXAdmin: push source triggered - sending VTx command") VTXAdmin.pushToVtx() end end @@ -552,8 +561,6 @@ local WidgetUI = loadScript(uiPath)({ -- Widget lifecycle -- ============================================================================ -local lastBuilt6pos = -1 - local wgt = { zone = zone, options = options, @@ -566,12 +573,12 @@ end function wgt.refresh(_event, _touchState) wgt.background() - if lvgl.isFullScreen() and VTXAdmin.lastPos ~= lastBuilt6pos then - lastBuilt6pos = VTXAdmin.lastPos - FullScreenUI.build() - end end +-- The full-screen page is built once, on entry. Everything it shows updates in +-- place from there: every value is a per-frame callback or a control that polls +-- its get() -- see the ui/fullscreen.lua header for the constraint that keeps +-- that true. function wgt.update(newOptions) wgt.options = newOptions if lvgl.isFullScreen() then @@ -579,9 +586,7 @@ function wgt.update(newOptions) VTXAdmin.syncDesiredFromState() end FullScreenUI.build() - lastBuilt6pos = VTXAdmin.lastPos else - lastBuilt6pos = -1 WidgetUI.build(wgt.zone, wgt.options) end end diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index 92ad2cd..bfae839 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -24,9 +24,17 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSessionClass = getSessionClass(_crsfSingleton, params) end + if not _vtxPresetsStorage then + local getFileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua") + ---@diagnostic disable-next-line: need-check-nil + local fileStorage = getFileStorage() + local getPresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua") + ---@diagnostic disable-next-line: need-check-nil + _vtxPresetsStorage = getPresetsStorage(fileStorage) + end local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton, _crsfSessionClass) + return loadable(zone, options, _crsfSingleton, _crsfSessionClass, _vtxPresetsStorage) end local function refresh(widget, event, touchState) diff --git a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua index a75d884..b0eb390 100644 --- a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua +++ b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua @@ -1,11 +1,13 @@ --------------------------------------------------------------------------- -- 6POS Preset Storage -- --- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- --- (FileStorage); returns the PresetsStorage table. -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua with (FileStorage) -- +-- and shared by every widget instance; returns the PresetsStorage table.-- -- -- --- Pure settings store: the preset slots, sources and flags, and their -- --- persistence as a key=value file. The automation that acts on them -- --- lives in VTXAdmin. -- +-- Settings store: six preset collections, the active collection, the -- +-- sources and flags, and their persistence as a key=value file -- plus -- +-- the radio-wide automation latches, which live here because an edge -- +-- must be consumed exactly once per radio. The automation that acts on -- +-- them lives in VTXAdmin. -- --------------------------------------------------------------------------- local FileStorage = ... @@ -13,11 +15,55 @@ local FileStorage = ... -- Where the settings live on the SD card local PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt" +-- Six collections of six slots, one slot per 6POS switch position. +local COLLECTION_COUNT = 6 +local SLOT_COUNT = 6 + -- The file layout, declared once: FileStorage writes these keys in this order. -local SAVE_KEYS = { "enabled", "source", "autoPushVtx", "pushSource", "p1", "p2", "p3", "p4", "p5", "p6" } +-- Scalars first, then one line per collection -- "band,channel" pairs joined +-- with ";". Worst case ~225 bytes, well inside FileStorage's bounded 512-byte +-- read; outgrowing that cap would truncate silently and the next save would +-- write defaults over the tail collections, so keep the budget in mind here. +local SAVE_KEYS = { "enabled", "source", "autoPushVtx", "pushSource", "collection" } +local COLLECTION_KEYS = {} +for c = 1, COLLECTION_COUNT do + COLLECTION_KEYS[c] = table.concat({ "c", c }) + SAVE_KEYS[#SAVE_KEYS + 1] = COLLECTION_KEYS[c] +end local PresetsStorage = { + -- Exposed for the editor's collection dropdown, so the count has one home. + COLLECTION_COUNT = COLLECTION_COUNT, + + -- collections[c][i] = { band, channel }, c and i both 1..6. + collections = {}, + + -- The active collection's slots: an alias of collections[collection], the + -- same table and never a copy, so writes through items land in the + -- collection. Every reader indexes PresetsStorage.items on each access and + -- none holds it across calls, so re-pointing it here switches the 6POS + -- automation, the cheatsheet and the editor rows at once. items = {}, + + -- Active collection, 1..6. Selects both what the 6POS switch applies and + -- what the full-screen preset rows edit -- one selector, no second mode. + collection = 1, + + -- Radio-wide automation latches, never persisted. They live on the shared + -- store rather than on a widget instance because an edge -- a 6POS movement, + -- a collection change, a push trigger -- must be consumed exactly once per + -- radio: the first instance whose session can act consumes it, and the rest + -- see no edge. + latch = { + lastPos = -1, -- last consumed 6POS position (cheatsheet highlight reads this) + lastCollection = -1, -- the collection lastPos was resolved through + stablePos = -1, -- debounce candidate + stableTime = 0, + ---@type boolean? push trigger level; nil until the source adopt seeds it + pushLastHigh = nil, + pushSourceSeen = 0, -- the source pushLastHigh was sampled from + }, + enabled = false, source = 0, -- 6POS source ID (0 = not configured) autoPushVtx = false, -- auto push to VTX on 6POS change @@ -33,66 +79,95 @@ local function splitBandChannel(val) return tonumber(string.sub(val, 1, comma - 1)), tonumber(string.sub(val, comma + 1)) end +--- Split a "band,channel;band,channel;..." collection line into up to +--- SLOT_COUNT slots, using plain string.find (no regex). Invalid or missing +--- segments stay nil so the caller fills defaults per slot. +local function splitSlots(val) + local slots = {} + local pos = 1 + for i = 1, SLOT_COUNT do + local semi = string.find(val, ";", pos, true) + local segment + if semi then + segment = string.sub(val, pos, semi - 1) + pos = semi + 1 + else + segment = string.sub(val, pos) + pos = #val + 1 + end + local band, channel = splitBandChannel(segment) + if band and channel then + slots[i] = { band = band, channel = channel } + end + end + return slots +end + --- Schema: enabled/autoPushVtx are "1"/"0" booleans, source/pushSource are ---- source IDs, p1..p6 are "band,channel" pairs defaulting to Raceband R1..R6. +--- source IDs, collection is the active index 1..6, and c1..c6 are collection +--- lines of "band,channel" pairs joined with ";", slots defaulting to +--- Raceband R1..R6. function PresetsStorage.load() local kv = FileStorage.read(PATH) or {} - local enabled = (kv.enabled == "1") - local source = tonumber(kv.source) or 0 - local autoPushVtx = (kv.autoPushVtx == "1") - local pushSource = tonumber(kv.pushSource) or 0 - local p = {} - for i = 1, 6 do - local val = kv[table.concat({ "p", i })] - if val then - local b, ch = splitBandChannel(val) - if b and ch then - p[i] = { band = b, channel = ch } + PresetsStorage.enabled = (kv.enabled == "1") + PresetsStorage.source = tonumber(kv.source) or 0 + PresetsStorage.autoPushVtx = (kv.autoPushVtx == "1") + PresetsStorage.pushSource = tonumber(kv.pushSource) or 0 + + local collections = {} + for c = 1, COLLECTION_COUNT do + local slots = splitSlots(kv[COLLECTION_KEYS[c]] or "") + -- Fill missing slots with Raceband defaults (R1..R6) + for i = 1, SLOT_COUNT do + if not slots[i] then + slots[i] = { band = 5, channel = i } end end - -- Fill missing positions with Raceband defaults (R1..R6) - if not p[i] then - p[i] = { band = 5, channel = i } - end + collections[c] = slots end - PresetsStorage.items = p - PresetsStorage.enabled = enabled - PresetsStorage.source = source - PresetsStorage.autoPushVtx = autoPushVtx - PresetsStorage.pushSource = pushSource - - print(table.concat({ - "VTXAdmin: presets loaded - enabled=", - tostring(enabled), - " source=", - source, - " autoPushVtx=", - tostring(autoPushVtx), - " pushSource=", - pushSource, - })) - for i = 1, 6 do - print(table.concat({ "VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel })) + PresetsStorage.collections = collections + + local collection = tonumber(kv.collection) or 1 + if collection < 1 or collection > COLLECTION_COUNT then + collection = 1 end + PresetsStorage.collection = collection + PresetsStorage.items = collections[collection] end function PresetsStorage.save() - print(table.concat({ "VTXAdmin: saving presets to ", PATH })) local values = { enabled = PresetsStorage.enabled and "1" or "0", source = PresetsStorage.source, autoPushVtx = PresetsStorage.autoPushVtx and "1" or "0", pushSource = PresetsStorage.pushSource, + collection = PresetsStorage.collection, } - for i = 1, 6 do - values[table.concat({ "p", i })] = - table.concat({ PresetsStorage.items[i].band, ",", PresetsStorage.items[i].channel }) + for c = 1, COLLECTION_COUNT do + local slots = PresetsStorage.collections[c] + local parts = {} + for i = 1, SLOT_COUNT do + parts[i] = table.concat({ slots[i].band, ",", slots[i].channel }) + end + values[COLLECTION_KEYS[c]] = table.concat(parts, ";") + end + FileStorage.write(PATH, SAVE_KEYS, values) +end + +--- Make a collection active and persist the choice. A single assignment, never +--- an in-place rebuild: display.lua and fullscreen.lua index items[i] from +--- LVGL callbacks that can land on any frame, and a half-built table there is +--- a nil index -- which makes the build fail silently and the whole UI vanish. +function PresetsStorage.selectCollection(c) + if c < 1 or c > COLLECTION_COUNT then + return end - if FileStorage.write(PATH, SAVE_KEYS, values) then - print("VTXAdmin: presets saved OK") - else - print(table.concat({ "VTXAdmin: ERROR - could not open ", PATH, " for writing" })) + if c == PresetsStorage.collection then + return end + PresetsStorage.collection = c + PresetsStorage.items = PresetsStorage.collections[c] + PresetsStorage.save() end -- Initialize presets from file diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index 8a09480..4bfa7e4 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -194,7 +194,7 @@ function VTXDisplay.build6posLabels(font) type = lvgl.LABEL, font = font or SMLSIZE, color = function() - return (VTXAdmin.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED + return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED end, text = function() local p = PresetsStorage.items[idx] diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua index 9a88631..4facb68 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua @@ -6,6 +6,14 @@ -- One layout for every screen size: VTX Settings editing -- -- VTXAdmin.desired, the Send VTx button, 6POS Quick Change and preset -- -- slot rows saving through PresetsStorage, and the no-module checklist. -- +-- -- +-- The page is built once, on full-screen entry, and never rebuilt, so -- +-- focus and scroll survive every value change. Values that change under -- +-- it -- a collection switch swapping the preset rows, a 6POS move -- +-- rewriting VTXAdmin.desired -- reach the screen through each control's -- +-- get() callback. choice and toggle re-poll it on every supported -- +-- firmware; numberEdit re-polls only on EdgeTX 2.12.0+, so on older -- +-- firmware its digits catch up when the page is next entered. -- --------------------------------------------------------------------------- local VTXAdmin, PresetsStorage = ... @@ -19,6 +27,13 @@ local FullScreenUI = {} -- Portrait screens get a narrower label column to leave more room for controls. local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 +-- One label per collection, from the storage's count -- the only place the +-- number of collections lives. +local COLLECTION_VALUES = {} +for i = 1, PresetsStorage.COLLECTION_COUNT do + COLLECTION_VALUES[i] = table.concat({ "Collection ", i }) +end + local function createRow(container, label, hint, visibleFn) local row = container:rectangle({ w = lvgl.PERCENT_SIZE + 100, @@ -275,7 +290,6 @@ function FullScreenUI.build() end, function(v) PresetsStorage.pushSource = v or 0 - VTXAdmin.pushLastHigh = nil PresetsStorage.save() end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, @@ -285,7 +299,13 @@ function FullScreenUI.build() -- Presets section createSectionHeader(fields, "Presets") - createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") + createChoiceRow(fields, "Collection", COLLECTION_VALUES, function() + return PresetsStorage.collection + end, function(idx) + PresetsStorage.selectCollection(idx) + end) + + createHintRow(fields, "Assign a Band and Channel to each 6POS switch position. Switching collection swaps all six.") local bandValues = { "--", "A", "B", "E", "F", "R", "L" } for i = 1, 6 do From b1c81d4aab984bd4ead8d2bca0ceb3073713c461 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 16:10:47 +0300 Subject: [PATCH 170/218] Fix out-of-memory on the tool's first launch on B&W radios A bare .lua install ran out of memory opening the tool on the GX12: EdgeTX compiles each file to bytecode on first load, no GC runs between loadScript calls, and the seven-file chain stacks its parse peaks until the heap is gone. Later launches worked because every attempt caches a few more .luac files. main.lua now loads through loadPart(), which collects garbage before each load, and the parse path itself gets smaller. Frames are now consumed pull-style everywhere. The firmware replicates every incoming frame into each widget instance's private queue and pop() reads the queue of the instance whose code is running, so each consumer drains its own replica through crsf.drain(consumer, onFrame) -- the tool and VTX Admin through their session, the telemetry widget through elrsinfo. The handler registry, poll() and attachBus() go away, and with them the tick guard under which only one instance's queue was popped per tick while the others accumulated stale backlog. hasTelemetry derives inside pop() as a drain empties, so frames always land before a consumer can observe the flip. Decoding sheds its allocation churn: the link-layer readString converts bytes to chars in place (each frame type has exactly one string-decoding consumer per instance), and the parameter codec refills a text selection's caller-owned values table in place, bumping field.valuesRev for the LVGL UI to watch instead of table identity. Folder-name suffix stripping moves into ui/lvgl.lua, its only consumer. The generic telemetry reader moves to sensors.lua; crsf.lua loads it and exposes crsf.getSensorValue, the seam setMock() patches exactly like pop and push. shim.lua slims to the tableConcat polyfill, the simulator carries its own shim, and reloading at the tool's root goes through session:discoverDevices() instead of raw pings. The CRSF mock now mirrors the firmware's replication: frames append to a shared log and every consumer advances its own cursor, receiving a fresh copy per delivery. Verified in the simulator on TX16S (both VTX Admin instances, telemetry widget, choice edits with sibling reload bursts) and GX12 (full load chain, edits, root rediscovery). --- docs/development.md | 28 ++-- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 83 +++++++--- src/SCRIPTS/CRSFSimulator/shim.lua | 78 ++++++++++ src/SCRIPTS/ELRS/crsf.lua | 153 +++++++------------ src/SCRIPTS/ELRS/crsf_elrsinfo.lua | 43 ++++-- src/SCRIPTS/ELRS/crsf_params.lua | 158 ++++++++++++-------- src/SCRIPTS/ELRS/crsf_session.lua | 57 ++----- src/SCRIPTS/ELRS/sensors.lua | 34 +++++ src/SCRIPTS/ELRS/shim.lua | 43 +----- src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 29 +++- src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua | 6 +- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 16 +- src/WIDGETS/ELRSTelemetry/loadable.lua | 2 +- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 4 +- 14 files changed, 426 insertions(+), 308 deletions(-) create mode 100644 src/SCRIPTS/CRSFSimulator/shim.lua create mode 100644 src/SCRIPTS/ELRS/sensors.lua diff --git a/docs/development.md b/docs/development.md index c592f15..12bed04 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,30 +43,40 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | Module | Purpose | |--------|---------| -| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | +| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | +| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | | `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded only by the VTX Admin widget | -| `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions | +| `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | -Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table: `crsf:poll()` hands the -same table to every registered handler, so an in-place decode would corrupt the frame for sibling -handlers. `reassemble()` serves both receive models: the tool drains `crsf.pop()` itself and passes -the field id it is waiting for, while widgets register a handler and pass `data[3]` to accept any -field from their device. +Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and +the firmware delivers every widget instance its own copy of each incoming frame, so **each script +instance has exactly one draining consumer**: the tool and the VTX Admin widget drain through +`session:drain()`, the telemetry widget through `elrsinfo:drain()`. A future widget needing two +consumers must pop once and route the frames itself. `reassemble()` callers pass the field id they +are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`, +used by VTX Admin so sibling instances stay in sync from each other's answers). + +Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table, with one deliberate +exception: the link-layer `readString` in `crsf.lua` converts bytes to chars in place, which is safe +because each frame type has exactly one string-decoding consumer. The codec stays read-only because +`reassemble()`'s single-frame fast path hands the caller's frame back as the decode buffer. ## CRSF Simulator The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`. -**File:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` +**Files:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` (the mock), `src/SCRIPTS/CRSFSimulator/shim.lua` (its `table.concat`/`table.remove`/`charsToString` helpers) The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. +Delivery mirrors the firmware's per-widget queue replication: frames append to a shared log and every consumer (the identity passed to `pop()`) advances its own cursor over it, receiving its own copy of each frame. Several widgets can therefore drain "their" queues concurrently against the one mock, just as they do against the firmware's real per-instance queues. + ### How it works -When running in the EdgeTX simulator (version string ends with `-simu`), `SCRIPTS/ELRS/crsf.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` at load time and patches its `pop`, `push`, `hasCrsfModule`, and `getSensorValue` functions with the mock implementations. The tool and the widgets both talk to CRSF through that library, so the mock covers all of them. +When running in the EdgeTX simulator (version string ends with `-simu`), `SCRIPTS/ELRS/crsf.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` at load time and patches its `pop`, `push`, `getSensorValue` and `hasCrsfModule` functions with the mock implementations. The tool and the widgets both talk to CRSF through that library, so the one mock instance covers all of them. Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to real hardware. diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index ed04d49..1630e57 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -10,9 +10,8 @@ -- ============================================================================ -- B&W radios ship without the table library, so every table.* call here goes --- through the shared ELRS compat layer. Loading this eagerly is safe: the ELRS --- library is always installed, while this mock is dev-only. -local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() +-- through the simulator's own compat layer. +local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() -- ============================================================================ -- Configuration: Change scenario here to test different states @@ -137,11 +136,22 @@ local pwmChannelConfig = { } -- ============================================================================ --- FIFO Packet Queue +-- Packet delivery: a frame log with per-consumer cursors +-- +-- Real firmware replicates every incoming frame into each widget instance's +-- private queue, so every consumer sees every frame. The mock emulates that: +-- frames append to a shared log, and each consumer (the identity passed to +-- pop()) advances its own cursor over it. A new consumer starts at the +-- current tail, matching the firmware's lazy queue creation. Each delivery +-- hands out a fresh copy of the data table -- consumers decode in place, +-- exactly as they may with the firmware's per-queue copies. -- ============================================================================ -local packetQueue = {} -local queueHead = 1 +local frameLog = {} +local logTotal = 0 +local logPruned = 0 +local cursors = setmetatable({}, { __mode = "k" }) +local defaultConsumer = {} -- Deferred packets simulate OTA relay delay (e.g., RX DEVICE_INFO arriving -- later than TX DEVICE_INFO). They are delivered in the NEXT poll cycle, @@ -165,32 +175,63 @@ local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20ms delay local folderNamesReadyAt = 0 local folderNamesDevice = nil +-- Drop log entries every cursor has passed +local function pruneLog() + local minCursor = logTotal + for _, c in pairs(cursors) do + if c < minCursor then + minCursor = c + end + end + for i = logPruned + 1, minCursor do + frameLog[i] = nil + end + logPruned = minCursor +end + local function queuePush(command, data) - packetQueue[#packetQueue + 1] = { command = command, data = data } + logTotal = logTotal + 1 + frameLog[logTotal] = { command = command, data = data } + pruneLog() end local function queuePushDeferred(command, data) deferredQueue[#deferredQueue + 1] = { command = command, data = data } end -local function queuePop() - -- Serve from main queue first - if queueHead <= #packetQueue then - local pkt = packetQueue[queueHead] - queueHead = queueHead + 1 - deferredReady = false - return pkt.command, pkt.data +-- Deliver the frame at index to a consumer as a fresh data-table copy +local function deliver(consumer, index) + cursors[consumer] = index + local pkt = frameLog[index] + local data = {} + for i = 1, #pkt.data do + data[i] = pkt.data[i] + end + return pkt.command, data +end + +local function queuePop(consumer) + consumer = consumer or defaultConsumer + local cursor = cursors[consumer] + if cursor == nil then + cursor = logTotal + cursors[consumer] = cursor end - -- Main queue empty, reset it - packetQueue = {} - queueHead = 1 + -- Serve the next unread log entry first + if cursor < logTotal then + deferredReady = false + return deliver(consumer, cursor + 1) + end - -- Serve deferred packets only after a nil has been returned (next poll cycle) + -- At the tail: serve deferred packets only after a nil has been returned + -- (next poll cycle). Promotion appends to the log, so every consumer + -- sees the deferred frame. if deferredReady and #deferredQueue > 0 then local pkt = shim.tableRemove(deferredQueue, 1) ---@diagnostic disable-next-line: need-check-nil - return pkt.command, pkt.data + queuePush(pkt.command, pkt.data) + return deliver(consumer, cursors[consumer] + 1) end -- Mark deferred as ready for the next poll cycle @@ -1406,7 +1447,7 @@ end -- mockPop: Returns next queued packet or nil -- ============================================================================ -local function mockPop() +local function mockPop(consumer) if not startTime then startTime = getTime() end @@ -1424,7 +1465,7 @@ local function mockPop() end end - local command, data = queuePop() + local command, data = queuePop(consumer) -- Apply deferred folder name updates once enough real time has elapsed. -- Until then, any PARAMETER_READ for a folder returns the stale dynName. diff --git a/src/SCRIPTS/CRSFSimulator/shim.lua b/src/SCRIPTS/CRSFSimulator/shim.lua new file mode 100644 index 0000000..c0606fd --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/shim.lua @@ -0,0 +1,78 @@ +--------------------------------------------------------------------------- +-- Simulator Compatibility Layer -- +-- -- +-- Polyfills and byte-array helpers for the CRSF simulator mock. The -- +-- simulator also runs on B&W builds, where table.concat, table.remove -- +-- and table.unpack are missing. -- +-- -- +-- Usage: local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() -- +--------------------------------------------------------------------------- + +local shim = {} + +-- ============================================================================ +-- table.concat polyfill +-- On color LCD radios the table library is available; on B&W it is not. +-- ============================================================================ + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +-- ============================================================================ +-- table.remove polyfill +-- Removes and returns the element at pos (default: last element). +-- Shifts subsequent elements down to close the gap. +-- ============================================================================ + +if table and table.remove then + shim.tableRemove = table.remove +else + shim.tableRemove = function(t, pos) + local n = #t + if n == 0 then + return nil + end + pos = pos or n + local val = t[pos] + for i = pos, n - 1 do + t[i] = t[i + 1] + end + t[n] = nil + return val + end +end + +-- ============================================================================ +-- Byte array -> string +-- Stands in for string.char(table.unpack(t)), which needs the table library. +-- Deliberately iterative: a pure-Lua unpack has to recurse once per element +-- (and `return t[i], f(...)` is not a tail call, so it cannot be optimised +-- away), which is not something to hand EdgeTX's Lua stack. +-- ============================================================================ + +function shim.charsToString(t, i, j) + local parts = {} + for k = i or 1, j or #t do + parts[#parts + 1] = string.char(t[k]) + end + return shim.tableConcat(parts) +end + +return shim diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 28bfce5..7ecb99d 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -1,20 +1,19 @@ --------------------------------------------------------------------------- -- CRSF Protocol Singleton -- -- -- --- Allows multiple widgets to share a single CRSF connection. -- --- crossfireTelemetryPop() is a destructive queue — each frame can only -- --- be read once. This singleton is the sole consumer: it drains the -- --- queue in poll() and fans each frame out to every widget that -- --- registered a handler for that frame type. Widgets never call -- --- crossfireTelemetryPop() directly; they register callbacks via -- --- registerHandler() and push outgoing frames through CRSF.push(). -- +-- Shared CRSF transport: protocol constants, pop/push wrappers (and -- +-- their simulator mock seam) and link-layer decoders. Frames are -- +-- consumed pull-style: every consumer drains its own script instance's -- +-- queue through CRSF.drain() (the firmware replicates incoming frames -- +-- into each widget instance's private queue on color radios; on B&W the -- +-- standalone tool is the only consumer). Derived link state -- +-- (hasTelemetry) refreshes as a drain empties the queue. -- -- -- -- Loaded once via loadScript() from /SCRIPTS/ELRS/crsf.lua. -- --- Returns a table with protocol constants, handler registry, and -- --- pop queue dispatcher. -- --------------------------------------------------------------------------- local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() +local sensors = loadScript("/SCRIPTS/ELRS/sensors.lua")() local CRSF = {} @@ -84,28 +83,54 @@ CRSF.CONST = { -- Internal state -- ============================================================================ --- Handler registry: frameType -> { callback1, callback2, ... } -CRSF._handlers = {} - --- Link state: derived from RQly in poll() +-- Link state: derived from RQly as a drain empties the queue CRSF.hasTelemetry = false --- Tick guard for poll() -CRSF._lastPollTick = 0 - -- ============================================================================ -- Default telemetry wrappers: delegate to real EdgeTX functions --- When mocking is active, setMock() replaces these with mock implementations +-- When mocking is active, setMock() replaces the underlying implementations -- ============================================================================ -function CRSF.pop() +--- Pop one frame from the calling script instance's queue. +-- consumer identifies the caller to the simulator mock, which emulates the +-- firmware's per-widget queue replication with per-consumer cursors; real +-- hardware ignores it. An empty-queue return is the end of a drain, so the +-- derived link state refreshes here: frames are always ingested before +-- consumers can observe a hasTelemetry flip. The ELRS TX zeroes RQly on +-- disconnect, so a present, positive value is the truth about the link. +function CRSF.pop(consumer) + local command, data = CRSF._popImpl(consumer) + if command == nil then + CRSF.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 + end + return command, data +end + +function CRSF._popImpl(_consumer) return crossfireTelemetryPop() end +--- Drain the calling script instance's queue, routing every frame through +-- onFrame(consumer, command, data). consumer is both the queue identity +-- handed to pop() and the receiver onFrame is invoked on, so consumers +-- pass their routing method directly: CRSF.drain(self, self._onFrame). +function CRSF.drain(consumer, onFrame) + local command, data + repeat + command, data = CRSF.pop(consumer) + if command then + onFrame(consumer, command, data) + end + until command == nil +end + function CRSF.push(command, data) return crossfireTelemetryPush(command, data) end +-- Read a telemetry sensor value by name (/SCRIPTS/ELRS/sensors.lua) +CRSF.getSensorValue = sensors.getSensorValue + function CRSF.hasCrsfModule() for modIdx = 0, 1 do local mod = model.getModule(modIdx) @@ -116,28 +141,6 @@ function CRSF.hasCrsfModule() return false end --- Field ID cache (string sensor name -> numeric ID) -CRSF._vCache = {} - ---- Read a telemetry sensor value by name. --- Caches the getFieldInfo string->ID lookup once it succeeds; a sensor that is --- not discovered yet is retried on every call, so it starts reading as soon as --- EdgeTX creates it (e.g. sensor discovery running after the widget loaded). --- getValue is called every time. --- setMock() replaces this function with the simulator's mock telemetry. -function CRSF.getSensorValue(id) - local cid = CRSF._vCache[id] - if cid == nil then - local info = getFieldInfo(id) - if info == nil then - return nil - end - cid = info.id - CRSF._vCache[id] = cid - end - return getValue(cid) -end - -- ============================================================================ -- Simulator integration (mirrors expresslrs.lua setMock pattern) -- ============================================================================ @@ -152,87 +155,41 @@ local function setMock() return end local mock = mockModule() - CRSF.pop = mock.pop + CRSF._popImpl = mock.pop CRSF.push = mock.push + CRSF.getSensorValue = mock.getSensorValue CRSF.hasCrsfModule = function() return mock.moduleFound end - CRSF.getSensorValue = mock.getSensorValue end setMock() ---@diagnostic disable-next-line: cast-local-type setMock = nil --- ============================================================================ --- Handler registry --- ============================================================================ - ---- Register a callback for a specific CRSF frame type. --- @param frameType numeric frame type (use CRSF.CONST.FRAMETYPE_*) --- @param callback function(data) called when a frame of this type is popped -function CRSF:registerHandler(frameType, callback) - if not self._handlers[frameType] then - self._handlers[frameType] = {} - end - self._handlers[frameType][#self._handlers[frameType] + 1] = callback -end - --- ============================================================================ --- Pop queue dispatcher --- ============================================================================ - ---- Drain the pop queue and dispatch frames to registered handlers. --- Guarded by a tick timestamp so only one effective poll runs per tick, --- even if multiple widgets call this. -function CRSF:poll() - local now = getTime() - if now == self._lastPollTick then - return - end - self._lastPollTick = now - - while true do - local command, data = CRSF.pop() - if command == nil then - break - end - local fh = self._handlers[command] - if fh then - for _, cb in ipairs(fh) do - cb(data) - end - end - end - - -- Connection state is derived from link quality at zero wire cost: the ELRS - -- TX zeroes RQly on disconnect, so a present, positive value is the truth - -- about the link. Derived after the drain so a frame from a dying connection - -- is dispatched before consumers observe the flip; handlers that keep - -- per-connection state key their resets off that ordering (see - -- /SCRIPTS/ELRS/crsf_elrsinfo.lua). - self.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 -end - -- ============================================================================ -- Shared helpers -- ============================================================================ ---- Read a null-terminated string from a CRSF data array without mutating it. --- poll() hands the same data table to every handler registered for a frame --- type, so decoding must never write into the frame. +--- Read a null-terminated string from a CRSF data array, converting the +-- bytes to chars in place and concatenating the slice. The frame table is +-- freshly allocated by every pop, and each frame type has exactly one +-- string-decoding consumer (DEVICE_INFO and ELRS_STATUS decode only here), +-- so mutating it is safe and saves a parts table per string. A second +-- decode of the same frame would raise (string.char on a string) rather +-- than corrupt silently. -- @param data array of byte values -- @param off 1-based start offset -- @return string, nextOffset local function readString(data, off) - local parts = {} + local startOff = off local b = data[off] while b and b ~= 0 do - parts[#parts + 1] = string.char(b) + data[off] = string.char(b) off = off + 1 b = data[off] end - return shim.tableConcat(parts), off + 1 + return shim.tableConcat(data, nil, startOff, off - 1), off + 1 end --- Decode a DEVICE_INFO (0x29) frame. diff --git a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua index d0963cc..3c453ed 100644 --- a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua +++ b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua @@ -3,12 +3,13 @@ -- -- -- Opt-in stateful companion to the CRSF singleton: DEVICE_INFO cache -- -- (module name, version-keyed RFMOD/RFRSSI lookup tables) and the -- --- per-connection model-match status, fed by frame handlers registered -- --- on the shared CRSF singleton. Loaded once per Lua state and shared by -- --- every widget instance; widgets that do not need this data never load -- --- it, so they never pay for the tables. -- +-- per-connection model-match status, fed by drain(). Loaded once per -- +-- Lua state and shared by every widget instance; widgets that do not -- +-- need this data never load it, so they never pay for the tables. -- -- -- --- update() is the per-tick pump: call it right after crsf:poll(). -- +-- Per background tick: elrsinfo:drain() to ingest the queue (which also -- +-- refreshes crsf.hasTelemetry as it empties), then elrsinfo:update() to -- +-- pump the outgoing requests. -- --------------------------------------------------------------------------- local crsf = ... @@ -108,13 +109,13 @@ local function updateModelMatch() crsf:requestElrsStatus() end ---- Per-tick pump; call right after crsf:poll() so the tick's frames are --- dispatched and hasTelemetry is current. Any hasTelemetry edge wipes the --- per-connection state before updateModelMatch() runs: a stale modelMismatch --- can neither survive a disconnect nor suppress the next connection's status --- poll via a late-arriving answer. poll() dispatches frames before it derives --- hasTelemetry, so a dying connection's ELRS_STATUS always lands before the --- edge is observed here. +--- Per-tick pump; call right after drain() so the tick's frames are +-- ingested and hasTelemetry is current. Any hasTelemetry edge wipes the +-- per-connection state before updateModelMatch() runs: a stale +-- modelMismatch can neither survive a disconnect nor suppress the next +-- connection's status poll via a late-arriving answer. hasTelemetry only +-- refreshes as a drain empties the queue, so a dying connection's +-- ELRS_STATUS always lands before the edge is observed here. function ElrsInfo:update() local connected = crsf.hasTelemetry if connected ~= self._wasConnected then @@ -289,8 +290,22 @@ local function onElrsStatus(data) ElrsInfo.modelMismatch = status.modelMismatch end -crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) -crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) +--- Route one frame into the info cache. Frames of other types are +-- dropped: this singleton is its widget's only queue consumer (pop is +-- destructive per instance), and the firmware delivers every instance its +-- own copy of each frame. +function ElrsInfo:_onFrame(command, data) + if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then + onDeviceInfo(data) + elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then + onElrsStatus(data) + end +end + +--- Drain the calling script instance's pop queue into the info cache. +function ElrsInfo:drain() + crsf.drain(self, self._onFrame) +end -- ============================================================================ -- Return singleton diff --git a/src/SCRIPTS/ELRS/crsf_params.lua b/src/SCRIPTS/ELRS/crsf_params.lua index 4942b41..8006b0e 100644 --- a/src/SCRIPTS/ELRS/crsf_params.lua +++ b/src/SCRIPTS/ELRS/crsf_params.lua @@ -9,9 +9,10 @@ -- widgets never pay for it. Policy -- load queues, command popups, -- -- reload decisions, transport -- stays with the caller. -- -- -- --- Purity rule: nothing in this file mutates a frame data table. -- --- crsf:poll() hands the same table to every registered handler, so an -- --- in-place decode here would corrupt the frame for sibling handlers. -- +-- Purity rule: nothing in this file mutates a frame data table. The -- +-- single-frame fast path in reassemble() hands the caller's frame back -- +-- as the decode buffer, and decodeEntry may scan it more than once -- +-- (cached-name skip), so decoding must stay read-only. -- -- -- -- Loaded via loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf). -- -- Returns the codec table directly. -- @@ -40,48 +41,83 @@ function Params.readValue(data, offset, size) return result end ---- Read a null-terminated string, or a semicolon-separated option list, from --- a byte array without mutating it. Translates the legacy ELRS arrow bytes --- (0xC0/0xC1) to the EdgeTX CHAR_UP/CHAR_DOWN glyphs. +--- Read a null-terminated string from a byte array without mutating it. +-- Names, units and info strings never carry the legacy ELRS arrow bytes +-- (the firmware emits them only inside selection options), so no glyph +-- translation happens here. -- @param data array of byte values -- @param offset 1-based start offset -- @param last cached previous result: when given, decoding is skipped and -- it is returned as-is (the offset still advances past the -- terminator). Only safe while no reload has flagged the -- content as possibly changed. --- @param isOpts truthy to split on ';' and return a table of options --- @return string|table result, number nextOffset, number optCount (count of --- non-empty options; 0 when cached or not isOpts) -function Params.readStringOrOpts(data, offset, last, isOpts) - local r = last or (isOpts and {}) - local optParts = {} - local vcnt = 0 - repeat +-- @return string result (or last as-is), number nextOffset +function Params.readString(data, offset, last) + if last then local b = data[offset] + while b and b ~= 0 do + offset = offset + 1 + b = data[offset] + end + return last, offset + 1 + end + local parts = {} + local b = data[offset] + while b and b ~= 0 do + parts[#parts + 1] = string.char(b) offset = offset + 1 + b = data[offset] + end + return shim.tableConcat(parts), offset + 1 +end - if not last then - if r and (b == 59 or b == 0) then - r[#r + 1] = shim.tableConcat(optParts) - if #optParts > 0 then - vcnt = vcnt + 1 - optParts = {} - end - elseif b ~= 0 then - -- Translate legacy arrow bytes (0xC0/0xC1) from ELRS firmware - -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs - if b == 192 and CHAR_UP then - optParts[#optParts + 1] = CHAR_UP - elseif b == 193 and CHAR_DOWN then - optParts[#optParts + 1] = CHAR_DOWN - else - optParts[#optParts + 1] = string.char(b) - end +--- Read a null-terminated, semicolon-separated option list into the +-- caller-owned values table, refilling it in place so its identity is +-- stable for the field's lifetime. Empty options stay as "" entries (the +-- UI disables those slots); leftover slots from a previously longer list +-- are truncated. Translates the legacy ELRS arrow bytes (0xC0/0xC1) to the +-- EdgeTX CHAR_UP/CHAR_DOWN glyphs. +-- @param data array of byte values +-- @param offset 1-based start offset +-- @param values the caller-owned option table to refill +-- @return number nextOffset, number optCount (count of non-empty options), +-- boolean changed (any slot differs from the previous contents) +function Params.readOptions(data, offset, values) + local n = 0 + local vcnt = 0 + local changed = false + local optParts = {} + local b = data[offset] + while b do + offset = offset + 1 + if b == 59 or b == 0 then + local opt = shim.tableConcat(optParts) + n = n + 1 + if values[n] ~= opt then + values[n] = opt + changed = true end + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + if b == 0 then + break + end + elseif b == 192 and CHAR_UP then + optParts[#optParts + 1] = CHAR_UP + elseif b == 193 and CHAR_DOWN then + optParts[#optParts + 1] = CHAR_DOWN + else + optParts[#optParts + 1] = string.char(b) end - until b == 0 - - return (r or shim.tableConcat(optParts)), offset, vcnt + b = data[offset] + end + for i = #values, n + 1, -1 do + values[i] = nil + changed = true + end + return offset, vcnt, changed end -- ============================================================================ @@ -92,7 +128,7 @@ local function fieldUnsignedLoad(field, data, offset, size, unitoffset) field.value = Params.readValue(data, offset, size) field.min = Params.readValue(data, offset + size, size) field.max = Params.readValue(data, offset + 2 * size, size) - local unit = Params.readStringOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + local unit = Params.readString(data, offset + (unitoffset or (4 * size)), field.unit) field.unit = (unit ~= "") and unit or nil if size ~= 1 then field.size = size @@ -129,34 +165,34 @@ local function fieldFloatLoad(field, data, offset) end local function fieldTextSelLoad(field, data, offset) - local vcnt - local oldValues = field.values - local cached = field.dirty == nil and oldValues - field.values, offset, vcnt = Params.readStringOrOpts(data, offset, cached, true) - if not cached then + local cached = field.dirty == nil and field.values or nil + if cached then + -- Options already decoded and not flagged dirty: skip the blob + cached, offset = Params.readString(data, offset, cached) + else + local values = field.values + if values == nil then + values = {} + field.values = values + end + local vcnt, changed + offset, vcnt, changed = Params.readOptions(data, offset, values) field.disabled = (vcnt <= 1) or nil - -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) - if oldValues and #oldValues == #field.values then - local same = true - for i = 1, #field.values do - if oldValues[i] ~= field.values[i] then - same = false - break - end - end - if same then - field.values = oldValues - end + if changed then + -- Consumers watch this revision instead of table identity: the values + -- table is refilled in place and keeps its identity for the field's + -- lifetime. + field.valuesRev = (field.valuesRev or 0) + 1 end end field.value = data[offset] - local unit = Params.readStringOrOpts(data, offset + 4) + local unit = Params.readString(data, offset + 4) field.unit = (unit ~= "") and unit or nil field.dirty = nil end local function fieldStringLoad(field, data, offset) - field.value, offset = Params.readStringOrOpts(data, offset) + field.value, offset = Params.readString(data, offset) if #data >= offset then field.maxlen = data[offset] end @@ -165,7 +201,7 @@ end local function fieldCommandLoad(field, data, offset) field.status = data[offset] field.timeout = data[offset + 1] - local info = Params.readStringOrOpts(data, offset + 2) + local info = Params.readString(data, offset + 2) field.info = (info ~= "") and info or nil end @@ -218,10 +254,10 @@ end --- Feed one PARAMETER_SETTINGS_ENTRY frame into the reassembly. -- expectedFieldId selects the consumption model: a caller waiting on one --- specific field passes its id (nil while idle drops everything), a caller --- listening passively under the poll() fan-out passes data[3] to accept any --- field from deviceId -- the dataId gate then keeps a sibling-elicited entry --- for another field out of an in-flight buffer. +-- specific field passes its id (nil while idle drops everything), a passive +-- caller (acceptUnsolicited) passes data[3] to accept any field from +-- deviceId -- the dataId gate then keeps a sibling-elicited entry for +-- another field out of an in-flight buffer. -- Never mutates data, and never retains it: the single-frame fast path -- returns data itself as the buffer, valid only for the current call. -- @param rx the reassembly-state table @@ -313,7 +349,7 @@ end -- @param buffer byte array holding the payload -- @param offset 1-based offset of the parent byte within buffer -- @param cachedName pass the previous name to skip its decode (see --- readStringOrOpts); nil decodes it fresh +-- readString); nil decodes it fresh -- @return field, or nil when the entry is shorter than parent + type + one -- name byte (the caller should still drop it from its queue) function Params.decodeEntry(field, fieldId, buffer, offset, cachedName) @@ -325,7 +361,7 @@ function Params.decodeEntry(field, fieldId, buffer, offset, cachedName) field.parent = (buffer[offset] ~= 0) and buffer[offset] or nil field.type = bit32.band(buffer[offset + 1], 0x7f) field.hidden = bit32.btest(buffer[offset + 1], 0x80) or nil - field.name, offset = Params.readStringOrOpts(buffer, offset + 2, cachedName) + field.name, offset = Params.readString(buffer, offset + 2, cachedName) local load = handlers[field.type + 1] if load then load(field, buffer, offset) diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua index ff007d6..a422e0d 100644 --- a/src/SCRIPTS/ELRS/crsf_session.lua +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -13,9 +13,9 @@ -- widget instance each own one. Methods live on the shared metatable, -- -- so an instance costs one table plus its opts callbacks. -- -- -- --- Two receive models converge on _onFrame: a standalone tool drains -- --- the destructive pop queue itself (drain()), widgets register on the -- --- crsf.lua fan-out once (attachBus()) and crsf:poll() feeds them. -- +-- Frames arrive through drain(): every consumer pops its own script -- +-- instance's queue (the firmware replicates incoming frames per widget -- +-- instance on color radios) and _onFrame routes them internally. -- -- -- -- Loaded via loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf,params). -- -- Returns the CRSFSession class; construct with CRSFSession.new(opts). -- @@ -148,39 +148,20 @@ function CRSFSession:setDevice(device) return true end ---- Drain the destructive pop queue into the session (pull model, for a --- standalone tool that owns the queue). Widgets use attachBus() instead. +--- Drain the destructive pop queue into the session: the one receive path +-- for every consumer. The queue popped is the calling script instance's +-- own, so a session drains exactly the frames delivered to its owner. function CRSFSession:drain() - local command, data - repeat - command, data = crsf.pop() - if command then - self:_onFrame(command, data) - end - until command == nil + crsf.drain(self, self._onFrame) end ---- Register the session's frame handlers on the crsf.lua fan-out (push --- model, for widgets). Call once; crsf:poll() then feeds the session. -function CRSFSession:attachBus() - crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, function(data) - self:_onEntry(data) - end) - if self._discovery then - crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, function(data) - self:_onDeviceInfo(data) - end) - end - if self._trackStatus then - crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, function(data) - self:_onStatus(data) - end) - end - if self._detectV1 then - crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, function(data) - self:_onWrite(data) - end) - end +--- Ask every reachable device to announce itself (broadcast DEVICE_PING). +-- Answers land in .devices through the discovery routing, so this is the +-- policy-facing "refresh the device list now" -- the scheduler's own +-- cadence only pings while the list is still empty. Meaningful only with +-- opts.discovery. +function CRSFSession:discoverDevices() + crsf:pingDevices() end -- ============================================================================ @@ -364,11 +345,6 @@ end -- Store queries -- ============================================================================ ---- The field table for an id, or nil while it is unknown. -function CRSFSession:field(id) - return self._fields[id] -end - --- The device table for an address, or nil (opts.discovery fills .devices). function CRSFSession:getDevice(id) for _, device in ipairs(self.devices) do @@ -437,11 +413,6 @@ function CRSFSession:isReceivingChunks() return self.rx.chunk > 0 end ---- True while background preloading (opts.preload) is filling the store. -function CRSFSession:isPreloading() - return self._preloading ~= nil -end - --- True while writes are waiting in the paced write queue. function CRSFSession:isWriting() return self._writeHead <= self._writeTail diff --git a/src/SCRIPTS/ELRS/sensors.lua b/src/SCRIPTS/ELRS/sensors.lua new file mode 100644 index 0000000..4f365cf --- /dev/null +++ b/src/SCRIPTS/ELRS/sensors.lua @@ -0,0 +1,34 @@ +--------------------------------------------------------------------------- +-- Telemetry Sensor Reader -- +-- -- +-- Generic EdgeTX telemetry access: read a sensor value by name, with a -- +-- cached getFieldInfo string->ID lookup. Nothing here is CRSF-specific. -- +-- Loaded by /SCRIPTS/ELRS/crsf.lua, which exposes the reader to every -- +-- consumer as crsf.getSensorValue (and swaps in the simulator mock's -- +-- reader when active). -- +--------------------------------------------------------------------------- + +local Sensors = {} + +-- Field ID cache (string sensor name -> numeric ID) +Sensors._vCache = {} + +--- Read a telemetry sensor value by name. +-- Caches the getFieldInfo string->ID lookup once it succeeds; a sensor that is +-- not discovered yet is retried on every call, so it starts reading as soon as +-- EdgeTX creates it (e.g. sensor discovery running after the widget loaded). +-- getValue is called every time. +function Sensors.getSensorValue(id) + local cid = Sensors._vCache[id] + if cid == nil then + local info = getFieldInfo(id) + if info == nil then + return nil + end + cid = info.id + Sensors._vCache[id] = cid + end + return getValue(cid) +end + +return Sensors diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua index 1e69ad0..e7f05ba 100644 --- a/src/SCRIPTS/ELRS/shim.lua +++ b/src/SCRIPTS/ELRS/shim.lua @@ -1,8 +1,7 @@ --------------------------------------------------------------------------- -- B&W Compatibility Layer -- -- -- --- Polyfills for standard Lua library functions missing on B&W radios -- --- (table.concat, table.remove) and shared helpers (byte-array decoding). -- +-- Polyfill for table.concat, which is missing on B&W radios. -- -- -- -- Lives in /SCRIPTS/ELRS/ alongside crsf.lua so it is available to -- -- both color widgets and B&W telemetry scripts. -- @@ -37,44 +36,4 @@ else end end --- ============================================================================ --- table.remove polyfill --- Removes and returns the element at pos (default: last element). --- Shifts subsequent elements down to close the gap. --- ============================================================================ - -if table and table.remove then - shim.tableRemove = table.remove -else - shim.tableRemove = function(t, pos) - local n = #t - if n == 0 then - return nil - end - pos = pos or n - local val = t[pos] - for i = pos, n - 1 do - t[i] = t[i + 1] - end - t[n] = nil - return val - end -end - --- ============================================================================ --- Byte array -> string --- Stands in for string.char(table.unpack(t)), which needs the table library. --- Deliberately iterative: a pure-Lua unpack has to recurse once per element --- (and `return t[i], f(...)` is not a tail call, so it cannot be optimised --- away), which is not something to hand EdgeTX's Lua stack. --- ============================================================================ - -function shim.charsToString(t, i, j) - local parts = {} - for k = i or 1, j or #t do - parts[#parts + 1] = string.char(t[k]) - end - return shim.tableConcat(parts) -end - return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index 6fab531..ecf538b 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -15,10 +15,22 @@ local useLvgl = (lvgl ~= nil) -- Load shared modules -- ============================================================================ -local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")() -local params = loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf) -local CRSFSession = loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf, params) -local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() +-- A full collection before each load frees the previous compile's parser +-- scratch; the firmware runs no GC between loadScript calls, so on a fresh +-- install (no .luac yet) the compile peaks would otherwise stack. +local function loadPart(path, arg1, arg2) + collectgarbage("collect") + local chunk = loadScript(path) + if chunk == nil then + error(path) + end + return chunk(arg1, arg2) +end + +local crsf = loadPart("/SCRIPTS/ELRS/crsf.lua") +local params = loadPart("/SCRIPTS/ELRS/crsf_params.lua", crsf) +local CRSFSession = loadPart("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) +local Navigation = loadPart("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua") -- ============================================================================ -- App Module: business logic between the session and the UI @@ -84,7 +96,8 @@ function App.goBack() return Navigation.goBack() end --- Reload at root: switch back to TX device or reload fields + ping. +-- Reload at root: switch back to TX device or reload fields, then +-- re-discover so new devices appear. function App.reloadAtRoot() if session.deviceId ~= crsf.CONST.ADDRESS_TX then local txDevice = session:getDevice(crsf.CONST.ADDRESS_TX) @@ -94,7 +107,7 @@ function App.reloadAtRoot() else session:reloadAll() end - crsf:pingDevices() + session:discoverDevices() end -- ============================================================================ @@ -132,9 +145,9 @@ local function init() VERSION = VERSION, } if useLvgl then - UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua")(deps) + UI = loadPart("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua", deps) else - UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua")(deps) + UI = loadPart("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua", deps) end UI.init() -- The returned table stays on the standalone Lua stack and pins init(), diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua index 25e834b..bd749a0 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -34,14 +34,10 @@ end -- viewState: optional table of UI state to preserve (e.g. cursor position). -- Merged into the nav entry so the UI can restore it on goBack(). function Navigation.openFolder(folderId, folderName, viewState) - local baseName = folderName - if folderName then - baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName - end local entry = { type = Navigation.TYPE_FOLDER, id = folderId, - name = baseName, + name = folderName, } if viewState then for k, v in pairs(viewState) do diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 1622870..510b6aa 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -494,6 +494,14 @@ end -- ============================================================================ function UI.openFolder(folderId, folderName) + -- The subtitle shows the folder name without the dynamic value suffix + -- ExpressLRS embeds in it (e.g. "VTX Admin (R:4:2:P)"). + if folderName then + local par = string.find(folderName, " (", 1, true) + if par then + folderName = string.sub(folderName, 1, par - 1) + end + end App.enterFolder(folderId, folderName) UI.invalidate() end @@ -725,7 +733,7 @@ function UI.createToggleRow(pg, field) end function UI.createChoiceRow(pg, field) - local valuesRef = field.values + local valuesRef = field.valuesRev local choiceWidget local setting = pg:setting({ @@ -735,8 +743,10 @@ function UI.createChoiceRow(pg, field) if field.hidden then return false end - if field.values ~= valuesRef then - valuesRef = field.values + -- The values table is refilled in place (identity is stable); the + -- codec bumps valuesRev when the contents change. + if field.valuesRev ~= valuesRef then + valuesRef = field.valuesRev if choiceWidget then choiceWidget:set({ values = field.values or {} }) end diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 905f7b9..332c259 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -669,7 +669,7 @@ local wgt = { } function wgt.background() - crsf:poll() + elrsinfo:drain() elrsinfo:update() Telemetry.update() end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 86bb4f2..5ee7e8f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -257,8 +257,6 @@ session = CRSFSession.new({ responseTimeout = 50, -- always the local TX module onFieldUpdate = onField, }) --- Register on the shared CRSF singleton's fan-out -session:attachBus() -- ============================================================================ -- VTXAdmin: 6POS quick-change and push-trigger automation @@ -567,7 +565,7 @@ local wgt = { } function wgt.background() - crsf:poll() + session:drain() VTXAdmin.tick() end From 2a6de92f2ec54d37dc657a1f8686afedc968d76a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 18:45:07 +0300 Subject: [PATCH 171/218] Fix the B&W title warning lagging its status answer The title flash ran free: it was never re-anchored when the warning flags changed, so a fresh warning could wait a full flash period for its visible half (and start on the hidden one), and a cleared warning could linger to finish its cycle. Latch the flags byte and restart the flash on any change -- a new warning now starts visible on the frame its status answer lands, and a cleared one drops immediately. This restores the re-anchor the rewrite dropped from elrsV3.lua. In the CRSF simulator, drive the armed flag from CH5 (AUX1, the ELRS arm channel) the way the firmware samples handset->IsArmed(): every connected scenario ORs in the armed bit while CH5 is high, and the warning message now derives from the flags byte with the firmware's highest-bit-wins priority. Arming becomes testable mid-session by flipping a switch mapped to CH5. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 47 +++++++++++++++------ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 22 +++++++--- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 1630e57..175162f 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -48,6 +48,9 @@ local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() -- error (baud rate too low). Exercises the warning screen -- and the suppress-critical-errors write (field id 0x2E), -- which clears the flags until the script restarts. +-- +-- In every connected scenario the armed flag additionally follows CH5 (AUX1, +-- the ELRS arm channel): drive it high to arm mid-session, low to disarm. local config = { scenario = "normal", -- Largest frame the handset link carries (CRSF_MAX_PACKET_LEN on a fast @@ -1116,37 +1119,53 @@ end -- supressCriticalErrors): critical flag bits stay cleared afterwards. local criticalErrorsSuppressed = false +-- CH5 is AUX1, the ELRS arm channel: armed while it is high. +-- getOutputValue is 0-based, so 4 reads CH5. +local function isArmed() + return (getOutputValue(4) or 0) > 0 +end + local function getElrsFlags() + local flags if config.scenario == "reconnect" then - return isRxAvailable() and 0x01 or 0x00 + flags = isRxAvailable() and 0x01 or 0x00 elseif config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then - return 0x05 -- connected + model mismatch + flags = 0x05 -- connected + model mismatch elseif config.scenario == "armed" then - return 0x09 -- connected + armed + flags = 0x09 -- connected + armed elseif config.scenario == "critical_error" then if criticalErrorsSuppressed then - return 0x01 -- connected, critical bits suppressed + flags = 0x01 -- connected, critical bits suppressed + else + flags = 0x41 -- connected + baud rate error (critical) end - return 0x41 -- connected + baud rate error (critical) elseif config.scenario == "normal" or config.scenario == "slow_loading" or config.scenario == "single_antenna" or config.scenario == "weak_link" then - return 0x01 -- connected + flags = 0x01 -- connected else - return 0x00 -- no telemetry + flags = 0x00 -- no telemetry + end + -- Sampled per status answer while connected, like handset->IsArmed() + -- in sendELRSstatus() + if bit32.btest(flags, 0x01) and isArmed() then + flags = bit32.bor(flags, 0x08) end + return flags end -local function getElrsFlagsInfo() - if config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then - return "Model Mismatch" - elseif config.scenario == "armed" then - return "[ ! Armed ! ]" - elseif config.scenario == "critical_error" and not criticalErrorsSuppressed then +-- Highest set bit wins, matching the messages[] scan (7..0) in +-- sendELRSstatus(); the suppressed critical bit is already off in flags. +local function getElrsFlagsInfo(flags) + if bit32.btest(flags, 0x40) then return "Baud rate too low" + elseif bit32.btest(flags, 0x08) then + return "[ ! Armed ! ]" + elseif bit32.btest(flags, 0x04) then + return "Model Mismatch" end return "" end @@ -1313,7 +1332,7 @@ local function mockPush(command, data) -- Special case: ELRS status request (fieldId == 0) if fieldId == 0 then local flags = getElrsFlags() - local flagsInfo = getElrsFlagsInfo() + local flagsInfo = getElrsFlagsInfo(flags) local destAddr = data[2] or CRSF.ADDRESS_HANDSET queuePush(CRSF.FRAMETYPE_ELRS_STATUS, encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) return true diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 6fae5a7..ab034b7 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -31,6 +31,10 @@ end -- UI state -- ============================================================================ +-- Title warning flash half-period, in 10 ms ticks. Also paces the idle page +-- repaint (the flash tick sets forceRedraw), so halving it doubles that rate. +local WARN_FLASH_PERIOD = 100 + local UI = { -- Cursor/selection state (owned entirely by this module) lineIndex = 1, @@ -55,7 +59,8 @@ local UI = { -- Warning flashing titleShowWarn = nil, - titleShowWarnTimeout = 100, + titleShowWarnTimeout = 0, + titleWarnFlags = nil, -- last flags byte the flash phase was anchored to -- Warning dismissal (model mismatch) warningDismissedAt = nil, @@ -158,11 +163,18 @@ end -- ============================================================================ function UI.render(event, _touchState) - -- Warning flashing timer + -- Warning flash: any flags change re-anchors the phase, so a new warning + -- starts on its visible half and a cleared one disappears at once local time = getTime() - if time > UI.titleShowWarnTimeout then - UI.titleShowWarn = (session.status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil - UI.titleShowWarnTimeout = time + 100 + local flags = session.status.flags + if flags ~= UI.titleWarnFlags then + UI.titleWarnFlags = flags + UI.titleShowWarn = (flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK) or nil + UI.titleShowWarnTimeout = time + WARN_FLASH_PERIOD + UI.forceRedraw = true + elseif time > UI.titleShowWarnTimeout then + UI.titleShowWarn = (flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarnTimeout = time + WARN_FLASH_PERIOD UI.forceRedraw = true end From 5113ab2e938625c7d814374c4abb083ab9fa073e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:23:00 +0300 Subject: [PATCH 172/218] Add the MSP and command frame types to the CRSF transport --- src/SCRIPTS/ELRS/crsf.lua | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 7ecb99d..6059d18 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -36,6 +36,14 @@ CRSF.CONST = { FRAMETYPE_PARAMETER_READ = 0x2C, FRAMETYPE_PARAMETER_WRITE = 0x2D, FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_COMMAND = 0x32, + FRAMETYPE_MSP_REQ = 0x7A, + FRAMETYPE_MSP_RESP = 0x7B, + FRAMETYPE_MSP_WRITE = 0x7C, + + -- COMMAND (0x32) subcommands (crsf_command_e / crsf_subcommand_e) + COMMAND_SUBCMD_RX = 0x10, + COMMAND_SUBCMD_RX_BIND = 0x01, -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses) FIELD_UINT8 = 0, @@ -270,6 +278,19 @@ function CRSF:requestElrsStatus() CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 }) end +--- Send a COMMAND bind request. Addressed to the TX module it enters bind +-- mode, transmitting to any RX waiting in bind mode; addressed to the RX it +-- unbinds a connected receiver. +-- @param dest CRSF device address (use CRSF.CONST.ADDRESS_*); nil targets the TX +function CRSF:sendBindCommand(dest) + CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, { + dest or CRSF.CONST.ADDRESS_TX, + CRSF.CONST.ADDRESS_HANDSET, + CRSF.CONST.COMMAND_SUBCMD_RX, + CRSF.CONST.COMMAND_SUBCMD_RX_BIND, + }) +end + -- ============================================================================ -- Return singleton -- ============================================================================ From d84037390a63c4623b434da4470c31ccdd551d3d Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:26:05 +0300 Subject: [PATCH 173/218] Split the bind tool's MSP framing into a shared msp codec --- src/SCRIPTS/ELRS/msp.lua | 170 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/SCRIPTS/ELRS/msp.lua diff --git a/src/SCRIPTS/ELRS/msp.lua b/src/SCRIPTS/ELRS/msp.lua new file mode 100644 index 0000000..24badc9 --- /dev/null +++ b/src/SCRIPTS/ELRS/msp.lua @@ -0,0 +1,170 @@ +--------------------------------------------------------------------------- +-- MSP-over-CRSF Codec -- +-- Loaded via loadScript() with (crsf); returns the Msp table. -- +-- -- +-- Stateless encoders and decoders for MSP frames tunnelled in CRSF -- +-- (frame types MSP_REQ/MSP_RESP/MSP_WRITE). Encoders return -- +-- (frameType, payload) for the caller to crsf.push(); decoders never -- +-- mutate the frame. Single-frame v1 traffic only: every message this -- +-- repo speaks (ELRS RXTX_CONFIG) fits one CRSF frame, so the chunked -- +-- transfer protocol (sequence numbers, reassembly, XOR CRC) is not -- +-- implemented. The firmware verifies no MSP CRC on these frames -- +-- (RxTxEndpoint.cpp reads the payload by offset), so encoders append -- +-- none, and the decoder ignores anything past the declared size. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local Msp = {} + +-- ============================================================================ +-- Named protocol constants +-- ============================================================================ + +Msp.CONST = { + -- MSP-over-CRSF status byte: bits 0-3 sequence, bit 4 start-of-frame, + -- bits 5-6 MSP version, bit 7 error (responses only) + MSP_STARTFLAG = 0x10, + MSP_VERSION_V1 = 0x20, + MSP_ERRORFLAG = 0x80, + + -- MSP function ids (ExpressLRS msptypes.h; RXTX_CONFIG needs ELRS 4.1+) + MSP_ELRS_RXTX_CONFIG = 0x2D, + + -- MSP_ELRS_RXTX_CONFIG subcommands (first payload byte) + RXTX_UID = 0x00, + RXTX_BIND_PHRASE = 0x01, + RXTX_MODEL_ID = 0x0A, + + -- A bind phrase must fit a single un-chunked MSP_WRITE frame + PHRASE_MAX = 52, +} + +-- Header of every outgoing frame: version 1, start-of-frame, sequence 0. +-- Single-frame messages never advance the sequence. +local HEADER = Msp.CONST.MSP_VERSION_V1 + Msp.CONST.MSP_STARTFLAG + +-- Version bits mask within the status byte +local VERSION_MASK = 0x60 + +--- Build the shared MSP payload layout: extended-frame addressing, header, +-- size (bytes after fn: subcommand plus data), function id, then args. +local function encode(deviceId, handsetId, fn, args) + local payload = { deviceId, handsetId, HEADER, #args, fn } + for i = 1, #args do + payload[5 + i] = args[i] + end + return payload +end + +-- ============================================================================ +-- Encoders: return (frameType, payload) for crsf.push() +-- ============================================================================ + +--- Encode an MSP read request. +-- @param deviceId destination CRSF address +-- @param handsetId source CRSF address (the handset) +-- @param fn MSP function id +-- @param args array of payload bytes (subcommand first) +-- @return frameType, payload +function Msp.encodeRead(deviceId, handsetId, fn, args) + return crsf.CONST.FRAMETYPE_MSP_REQ, encode(deviceId, handsetId, fn, args) +end + +--- Encode an MSP write. +-- @param deviceId destination CRSF address +-- @param handsetId source CRSF address (the handset) +-- @param fn MSP function id +-- @param args array of payload bytes (subcommand first) +-- @return frameType, payload +function Msp.encodeWrite(deviceId, handsetId, fn, args) + return crsf.CONST.FRAMETYPE_MSP_WRITE, encode(deviceId, handsetId, fn, args) +end + +--- Encode a request for the device's current bind UID. +-- @return frameType, payload +function Msp.encodeUidRead(deviceId, handsetId) + return Msp.encodeRead(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, { Msp.CONST.RXTX_UID }) +end + +--- Encode a write of a raw 6-byte bind UID. +-- @param uid array of six byte values +-- @return frameType, payload +function Msp.encodeUidWrite(deviceId, handsetId, uid) + return Msp.encodeWrite(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, { + Msp.CONST.RXTX_UID, + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + }) +end + +--- Encode a write of a bind phrase; the device derives its UID from it. +-- @param phrase string of at most PHRASE_MAX chars +-- @return frameType, payload +function Msp.encodePhraseWrite(deviceId, handsetId, phrase) + local args = { Msp.CONST.RXTX_BIND_PHRASE } + for i = 1, #phrase do + args[i + 1] = string.byte(phrase, i) + end + return Msp.encodeWrite(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, args) +end + +-- ============================================================================ +-- Decoders +-- ============================================================================ + +--- Decode an MSP_RESP frame addressed to the handset. Accepts only v1 +-- single-frame responses (start flag set); continuation chunks and other +-- MSP versions return nil. Source gating stays with the caller, matching +-- decodeDeviceInfo's convention. +-- @param data array of byte values +-- @return srcId, fn, offset, len -- payload is data[offset .. offset+len-1] +-- (subcommand first), or nil when the frame is not a complete +-- single-frame v1 response to the handset +function Msp.decodeResponse(data) + if data[1] ~= crsf.CONST.ADDRESS_HANDSET then + return nil + end + local status = data[3] + local size = data[4] + if status == nil or size == nil then + return nil + end + if not bit32.btest(status, Msp.CONST.MSP_STARTFLAG) then + return nil + end + if bit32.band(status, VERSION_MASK) ~= Msp.CONST.MSP_VERSION_V1 then + return nil + end + local fn = data[5] + if fn == nil or data[5 + size] == nil then + return nil + end + return data[2], fn, 6, size +end + +--- Decode an RXTX_CONFIG/UID answer into the caller-owned out[1..6] +-- (reused across frames: no per-frame table). +-- @param data array of byte values +-- @param out table receiving the six UID bytes +-- @return srcId (source CRSF address) or nil when the frame is not a +-- complete UID response +function Msp.decodeUid(data, out) + local srcId, fn, offset, len = Msp.decodeResponse(data) + if srcId == nil or fn ~= Msp.CONST.MSP_ELRS_RXTX_CONFIG then + return nil + end + if data[offset] ~= Msp.CONST.RXTX_UID or len < 7 then + return nil + end + for i = 1, 6 do + out[i] = data[offset + i] + end + return srcId +end + +return Msp From cad9c0e0be18e3270c3aef202a359aa04bc5437a Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:26:23 +0300 Subject: [PATCH 174/218] Add the bind tool's defer timer to the shared library --- src/SCRIPTS/ELRS/defer.lua | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/SCRIPTS/ELRS/defer.lua diff --git a/src/SCRIPTS/ELRS/defer.lua b/src/SCRIPTS/ELRS/defer.lua new file mode 100644 index 0000000..508f7cd --- /dev/null +++ b/src/SCRIPTS/ELRS/defer.lua @@ -0,0 +1,52 @@ +--------------------------------------------------------------------------- +-- Deferred Callback Timer -- +-- Loaded via loadScript() with no arguments; returns the Defer table. -- +-- -- +-- A single-slot setTimeout: at most one callback is pending at a time, -- +-- and scheduling a new one replaces it. That replacement is the point, -- +-- not a limitation -- a consumer sequencing wire traffic (send, retry, -- +-- follow-up) wants a new action to cancel whatever was pending. A -- +-- consumer needing independent timers needs a different tool. -- +-- -- +-- Each loadScript() execution returns a fresh table, so every consumer -- +-- owns a private slot. poll() must be called once per run() tick. -- +--------------------------------------------------------------------------- + +local Defer = { + _cb = nil, +} + +--- Schedule fn(ctx) to run once no sooner than ticks from now, replacing +-- any pending callback. +-- @param ticks delay in getTime() units (10 ms) +-- @param fn callback +-- @param ctx passed to fn; nil is fine +function Defer.setTimeout(ticks, fn, ctx) + Defer._cb = { + start = getTime(), + ticks = ticks, + fn = fn, + ctx = ctx, + } +end + +--- Drop the pending callback, if any. +function Defer.clear() + Defer._cb = nil +end + +--- Run the pending callback when its delay has elapsed. The slot is +-- cleared before the call so the callback may schedule a successor. +function Defer.poll() + local cb = Defer._cb + if cb == nil then + return + end + if getTime() - cb.start < cb.ticks then + return + end + Defer._cb = nil + cb.fn(cb.ctx) +end + +return Defer From 83b87745db6eb5903a016b384a194c2eb8c81cb2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:26:49 +0300 Subject: [PATCH 175/218] Route FileStorage concatenation through the B&W shim --- src/SCRIPTS/ELRS/file_storage.lua | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/SCRIPTS/ELRS/file_storage.lua b/src/SCRIPTS/ELRS/file_storage.lua index 1c629d7..c3d94e0 100644 --- a/src/SCRIPTS/ELRS/file_storage.lua +++ b/src/SCRIPTS/ELRS/file_storage.lua @@ -7,6 +7,9 @@ -- typing and defaults belong to the caller. -- --------------------------------------------------------------------------- +-- B&W EdgeTX ships without the table library; the shim routes around it. +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + local FileStorage = {} -- A settings file is a handful of short lines; one bounded read keeps the @@ -68,10 +71,10 @@ function FileStorage.write(path, keys, values) for i = 1, #keys do local val = values[keys[i]] if val ~= nil then - lines[#lines + 1] = table.concat({ keys[i], "=", val, "\n" }) + lines[#lines + 1] = shim.tableConcat({ keys[i], "=", val, "\n" }) end end - io.write(f, table.concat(lines)) + io.write(f, shim.tableConcat(lines)) io.close(f) return true end From cf98356d9972d461c4bde2777c8f103d68749407 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:28:35 +0300 Subject: [PATCH 176/218] Teach the CRSF simulator the ELRS bind MSP traffic --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 96 +++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 175162f..ffd49e3 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -73,6 +73,16 @@ local CRSF = { FRAMETYPE_PARAMETER_READ = 0x2C, FRAMETYPE_PARAMETER_WRITE = 0x2D, FRAMETYPE_ELRS_STATUS = 0x2E, + FRAMETYPE_COMMAND = 0x32, + FRAMETYPE_MSP_REQ = 0x7A, + FRAMETYPE_MSP_RESP = 0x7B, + FRAMETYPE_MSP_WRITE = 0x7C, + + -- MSP-over-CRSF (single-frame v1: version 1 | start-of-frame | seq 0) + MSP_HEADER_V1 = 0x30, + MSP_ELRS_RXTX_CONFIG = 0x2D, + MSP_RXTX_UID = 0x00, + MSP_RXTX_BIND_PHRASE = 0x01, -- Addresses ADDRESS_BROADCAST = 0x00, @@ -1244,6 +1254,52 @@ local function handleCommandWrite(device, param, newStatus) param.info = state.info end +-- ============================================================================ +-- MSP bind UID state +-- ============================================================================ + +-- Per-device bind UID, read and written over MSP RXTX_CONFIG. TX and RX +-- deliberately start different so a fresh "normal" run shows a mismatch +-- that setting both to one phrase visibly fixes. +local mspUid = { + [CRSF.ADDRESS_TX] = { 13, 213, 105, 32, 0, 1 }, + [CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 2 }, +} + +--- Derive a deterministic 6-byte UID from bind-phrase bytes: the same +-- phrase always yields the same UID, so a phrase written to both devices +-- produces matching UIDs. Equality is the property the tool demonstrates; +-- the bytes themselves need not match the firmware's MD5 derivation. +local function deriveUid(chars) + local uid = { 0, 0, 0, 0, 0, 0 } + local acc = 0 + for i = 1, #chars do + acc = (acc + chars[i] * i) % 251 + local slot = (i - 1) % 6 + 1 + uid[slot] = (uid[slot] + acc + chars[i]) % 256 + end + return uid +end + +--- Build an MSP_RESP payload answering a RXTX_CONFIG/UID read. +-- Layout mirrors the request: header, size (subcmd + 6 bytes), fn, subcmd. +local function encodeMspUidResponse(deviceId, destAddr, uid) + return { + destAddr, + deviceId, + CRSF.MSP_HEADER_V1, + 7, + CRSF.MSP_ELRS_RXTX_CONFIG, + CRSF.MSP_RXTX_UID, + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + } +end + -- ============================================================================ -- mockPush: Processes commands sent by the Lua script -- ============================================================================ @@ -1456,6 +1512,46 @@ local function mockPush(command, data) end end return true + elseif command == CRSF.FRAMETYPE_MSP_REQ then + -- MSP read: data = { deviceId, handsetId, header, size, fn, subcmd } + local deviceId = data[1] + local replyTo = data[2] or CRSF.ADDRESS_HANDSET + if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and data[6] == CRSF.MSP_RXTX_UID then + if deviceId == CRSF.ADDRESS_TX then + queuePush(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId])) + elseif deviceId == CRSF.ADDRESS_RX and isRxAvailable() then + -- Relayed over the air: arrives in the next poll cycle. An absent RX + -- answers nothing, which is what drives the tool's bounded retry. + queuePushDeferred(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId])) + end + end + return true + elseif command == CRSF.FRAMETYPE_MSP_WRITE then + -- MSP write: data = { deviceId, handsetId, header, size, fn, subcmd, ... }. + -- The firmware sends no acknowledgement; the tool re-reads the UID. + local deviceId = data[1] + local reachable = deviceId == CRSF.ADDRESS_TX or (deviceId == CRSF.ADDRESS_RX and isRxAvailable()) + if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and reachable then + if data[6] == CRSF.MSP_RXTX_BIND_PHRASE then + -- size counts subcmd + phrase bytes, so the phrase ends at data[5 + size] + local chars = {} + for i = 7, 5 + (data[4] or 0) do + chars[#chars + 1] = data[i] + end + mspUid[deviceId] = deriveUid(chars) + elseif data[6] == CRSF.MSP_RXTX_UID then + local uid = {} + for i = 1, 6 do + uid[i] = data[6 + i] or 0 + end + mspUid[deviceId] = uid + end + end + return true + elseif command == CRSF.FRAMETYPE_COMMAND then + -- Bind/unbind requests. Log-only: the push line above already records + -- the destination, and the mock has no bound-state to change. + return true end -- Unknown command - ignore From 0d1450b076f11567e357dff2ebdddc115c0e1801 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Thu, 13 Aug 2026 23:32:56 +0300 Subject: [PATCH 177/218] Rearchitect the bind tool as ExpressLRSBind on the shared codec The PR's LVGL-only elrs-bind script becomes a folder tool mirroring the config tool's shape: main.lua owns the App state and pumps the pull-model transport (sole drainer, MSP_RESP routed through the msp codec), and one UI chunk loads at runtime. The UID retry is now bounded -- six silent attempts end in a "needs ELRS 4.1+" notice instead of wire spam forever. History persists through FileStorage as indexed h1..h5 keys, and the manifest gains the tool with the data file excluded, so pkg install finally ships it. --- .gitignore | 1 + edgetx.yml | 7 + .../TOOLS/ExpressLRSBind/history_storage.lua | 93 ++++ src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua | 349 +++++++++++++ src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua | 361 +++++++++++++ src/SCRIPTS/TOOLS/elrs-bind/main.lua | 483 ------------------ 6 files changed, 811 insertions(+), 483 deletions(-) create mode 100644 src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua create mode 100644 src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua delete mode 100644 src/SCRIPTS/TOOLS/elrs-bind/main.lua diff --git a/.gitignore b/.gitignore index 26e3198..531c909 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *.luac src/WIDGETS/ELRSVTXAdmin/presets.txt +src/SCRIPTS/TOOLS/ExpressLRSBind/history.txt bin/ .claude/plans/ TODO.md diff --git a/edgetx.yml b/edgetx.yml index 0fcd6ff..3e5f696 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -38,6 +38,13 @@ tools: depends: - ELRS + - name: ExpressLRSBind + path: SCRIPTS/TOOLS/ExpressLRSBind + depends: + - ELRS + exclude: + - history.txt + widgets: - name: ELRSTelemetry path: WIDGETS/ELRSTelemetry diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua new file mode 100644 index 0000000..9f7cd41 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua @@ -0,0 +1,93 @@ +--------------------------------------------------------------------------- +-- Bind Phrase History Storage -- +-- Loaded via loadScript() from ExpressLRSBind/main.lua with -- +-- (FileStorage); returns the History table. -- +-- -- +-- The last MAX phrases, newest first, persisted as indexed keys h1..hN -- +-- (the ELRSVTXAdmin presets c1..c6 idiom). Worst case -- +-- MAX * (3 + 52 + 1) = 280 bytes, inside FileStorage's bounded 512-byte -- +-- read; raising MAX or the phrase length must revisit that budget or -- +-- the tail entries truncate silently. No table.insert/remove: B&W -- +-- radios ship without the table library, so shifts are plain loops. -- +--------------------------------------------------------------------------- + +local FileStorage = ... + +-- Where the history lives on the SD card +local PATH = "/SCRIPTS/TOOLS/ExpressLRSBind/history.txt" + +local MAX = 5 + +-- The file layout, declared once: FileStorage writes these keys in this order. +local SAVE_KEYS = {} +for i = 1, MAX do + SAVE_KEYS[i] = "h" .. i +end + +local History = { + MAX = MAX, + -- items[1] is the most recent phrase + items = {}, +} + +local function save() + local values = {} + for i = 1, #History.items do + values[SAVE_KEYS[i]] = History.items[i] + end + FileStorage.write(PATH, SAVE_KEYS, values) +end + +--- Put phrase at the front, dropping an existing copy (a re-used phrase +-- moves up instead of duplicating) and trimming past MAX. +function History.add(phrase) + if phrase == nil or phrase == "" then + return + end + local items = History.items + for i = #items, 1, -1 do + if items[i] == phrase then + for j = i, #items - 1 do + items[j] = items[j + 1] + end + items[#items] = nil + end + end + for j = math.min(#items + 1, MAX), 2, -1 do + items[j] = items[j - 1] + end + items[1] = phrase + save() +end + +function History.remove(idx) + local items = History.items + if items[idx] == nil then + return + end + for j = idx, #items - 1 do + items[j] = items[j + 1] + end + items[#items] = nil + save() +end + +function History.clear() + History.items = {} + save() +end + +-- Initialize from file. h1..hN are read in order; the first missing key +-- ends the list, so a hand-edited file with gaps loads its head only. +local kv = FileStorage.read(PATH) +if kv then + for i = 1, MAX do + local v = kv[SAVE_KEYS[i]] + if v == nil then + break + end + History.items[i] = v + end +end + +return History diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua new file mode 100644 index 0000000..2bb81b6 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -0,0 +1,349 @@ +-- TNS|ExpressLRS Bind|TNE +---- ######################################################################### +---- # # +---- # ExpressLRS bind phrase manager for BW and color LCD radios # +---- # (EdgeTX 2.11.6+/2.12.1+). Reads and writes the bind phrase / UID # +---- # over MSP; the device side requires ExpressLRS 4.1+. # +---- # # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- ######################################################################### + +local VERSION = "r1" +local useLvgl = (lvgl ~= nil) + +-- ============================================================================ +-- Load shared modules +-- ============================================================================ + +-- A full collection before each load frees the previous compile's parser +-- scratch; the firmware runs no GC between loadScript calls, so on a fresh +-- install (no .luac yet) the compile peaks would otherwise stack. +local function loadPart(path, arg1) + collectgarbage("collect") + local chunk = loadScript(path) + if chunk == nil then + error(path) + end + return chunk(arg1) +end + +local crsf = loadPart("/SCRIPTS/ELRS/crsf.lua") +local msp = loadPart("/SCRIPTS/ELRS/msp.lua", crsf) +local defer = loadPart("/SCRIPTS/ELRS/defer.lua") +local FileStorage = loadPart("/SCRIPTS/ELRS/file_storage.lua") +local History = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua", FileStorage) + +-- ============================================================================ +-- App Module: business logic shared by both UI frontends +-- ============================================================================ + +-- Scheduler constants (getTime() ticks of 10 ms) +local UID_RETRY_TICKS = 50 -- resend an unanswered UID read after 500 ms +local UID_MAX_ATTEMPTS = 6 -- then give up: the device has no MSP config support +local FOLLOWUP_TICKS = 100 -- settle time before a follow-up after a write + +local App = { + -- Target selector positions (order matches the UIs' choice lists) + TARGET_TX = 1, + TARGET_RX = 2, + TARGET_BOTH = 3, + + target = 1, + phrase = "", + bothStep = nil, -- TARGET_RX while the Both sequence's RX leg is in flight + uid = {}, -- reused 6-slot byte table, filled by msp.decodeUid + uidFrom = nil, -- source address of the last UID answer; nil until one lands + statusText = "Idle", -- transient status line; nil shows the UID bytes + uidAttempts = 0, + -- Bumped only when a build-time snapshot must refresh (the TEXT_EDIT + -- value, the CHOICE selection). Everything else on the LVGL page reads + -- live getters, and a rebuild resets rotary focus -- so status and UID + -- updates must NOT bump this. + rev = 0, + history = History, + + shouldExit = false, + crsfModuleChecked = false, + crsfModuleFound = false, +} + +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + App.crsfModuleChecked = true + App.crsfModuleFound = crsf.hasCrsfModule() + return App.crsfModuleFound +end + +-- The TX module answers on the handset UART; the RX only over an active link. +function App.isTargetReachable() + return App.target == App.TARGET_TX or (App.target == App.TARGET_RX and crsf.hasTelemetry) +end + +function App.isTargetReachableOrBoth() + return App.target == App.TARGET_BOTH or App.isTargetReachable() +end + +--- Status line for the UIs: transient status while an exchange is in +-- flight, then the last UID answer. +function App.uidLine() + if App.statusText then + return App.statusText + end + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d, %d, %d, %d, %d, %d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) +end + +--- Parse one comma-separated segment as a plain decimal byte (surrounding +-- spaces allowed). No patterns: B&W-friendly byte walking. +local function parseByte(part) + local i = 1 + local j = #part + while i <= j and string.byte(part, i) == 32 do + i = i + 1 + end + while j >= i and string.byte(part, j) == 32 do + j = j - 1 + end + if i > j or j - i > 2 then + return nil + end + local n = 0 + for k = i, j do + local b = string.byte(part, k) + if b < 48 or b > 57 then + return nil + end + n = n * 10 + (b - 48) + end + if n > 255 then + return nil + end + return n +end + +--- Interpret the phrase text as a raw UID: 4-6 comma-separated bytes, +-- left-padded with zeros to 6. Returns nil when the text is a phrase. +function App.parseUidText(text) + local bytes = {} + local pos = 1 + while pos <= #text do + local comma = string.find(text, ",", pos, true) + local part + if comma then + part = string.sub(text, pos, comma - 1) + pos = comma + 1 + else + part = string.sub(text, pos) + pos = #text + 1 + end + local n = parseByte(part) + if n == nil then + return nil + end + bytes[#bytes + 1] = n + end + local count = #bytes + if count < 4 or count > 6 then + return nil + end + local uid = { 0, 0, 0, 0, 0, 0 } + for i = 1, count do + uid[6 - count + i] = bytes[i] + end + return uid +end + +--- Request the target's UID, retrying on silence. Bounded: an ELRS device +-- without MSP config support (pre-4.1) never answers, and the retry must +-- not spam the wire forever. +function App.requestUid() + if not App.isTargetReachable() then + App.statusText = "Idle" + return + end + if App.uidAttempts >= UID_MAX_ATTEMPTS then + App.statusText = "No response (needs ELRS 4.1+)" + return + end + App.uidAttempts = App.uidAttempts + 1 + App.statusText = "Updating..." + local dest = (App.target == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + crsf.push(msp.encodeUidRead(dest, crsf.CONST.ADDRESS_HANDSET)) + defer.setTimeout(UID_RETRY_TICKS, App.requestUid) +end + +--- User-facing entry point: start a fresh UID request cycle. +function App.startUidRequest() + App.uidAttempts = 0 + App.requestUid() +end + +--- Send the phrase (or raw UID) to the selected target. "Both" is a +-- two-step sequence: the RX first -- writing its phrase drops it off the +-- link -- then the TX, after which the selector rests on Transmitter. +function App.sendSet() + if App.phrase == "" then + return + end + if App.target == App.TARGET_BOTH then + if App.bothStep == nil then + App.bothStep = App.TARGET_RX + else + App.bothStep = nil + -- The selector visibly rests on Transmitter after the sequence; the + -- CHOICE renders its build-time selection, so this needs a rebuild. + App.target = App.TARGET_TX + App.rev = App.rev + 1 + end + end + local effective = App.bothStep or App.target + if App.bothStep == nil then + App.statusText = (App.target == App.TARGET_TX) and "Setting transmitter..." or "Setting receiver..." + else + App.statusText = "Setting RX and disconnecting..." + end + local dest = (effective == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + local uid = App.parseUidText(App.phrase) + if uid then + crsf.push(msp.encodeUidWrite(dest, crsf.CONST.ADDRESS_HANDSET, uid)) + else + crsf.push(msp.encodePhraseWrite(dest, crsf.CONST.ADDRESS_HANDSET, App.phrase)) + end + History.add(App.phrase) + if App.bothStep == nil then + defer.setTimeout(FOLLOWUP_TICKS, App.startUidRequest) + else + defer.setTimeout(FOLLOWUP_TICKS, App.sendSet) + end +end + +local function markSent() + App.statusText = "Sent" +end + +--- Put the TX module in bind mode, catching an RX waiting in bind mode. +function App.sendBind() + App.statusText = "Sending bind command..." + crsf:sendBindCommand(crsf.CONST.ADDRESS_TX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) +end + +--- Unbind the connected receiver. +function App.sendUnbind() + App.statusText = "Sending unbind to RX..." + crsf:sendBindCommand(crsf.CONST.ADDRESS_RX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) +end + +function App.useHistory(i) + local phrase = History.items[i] + if phrase == nil then + return + end + App.phrase = phrase + App.rev = App.rev + 1 +end + +function App.removeHistory(i) + History.remove(i) +end + +function App.clearHistory() + History.clear() +end + +--- Frame router for crsf.drain(): the tool is the sole drainer in its Lua +-- state, and the only traffic it consumes is the MSP UID answer. +function App.onFrame(_consumer, command, data) + if command ~= crsf.CONST.FRAMETYPE_MSP_RESP then + return + end + local srcId = msp.decodeUid(data, App.uid) + if srcId == nil then + return + end + defer.clear() + App.uidFrom = srcId + App.statusText = nil + App.uidAttempts = 0 +end + +-- ============================================================================ +-- UI loading (deferred to init) +-- ============================================================================ + +local UI + +-- Module table, forward-declared so init() can drop itself once it has run. +local M = {} + +local function init() + local deps = { + App = App, + crsf = crsf, + msp = msp, + VERSION = VERSION, + } + App.phrase = History.items[1] or "" + if useLvgl then + UI = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua", deps) + else + UI = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua", deps) + end + UI.init() + -- One tick of delay so run()'s first drain creates the telemetry queue + -- before the request's answer can land. + defer.setTimeout(1, App.startUidRequest) + -- The returned table stays on the standalone Lua stack and pins init(), + -- which holds VERSION and useLvgl as upvalues. Drop it. + M.init = nil +end + +-- ============================================================================ +-- Run (shared orchestrator) +-- ============================================================================ + +local function run(event, touchState) + if event == nil then + return 2 + end + + -- UI-specific pre-checks (version gate on both LVGL and BW paths) + if UI.preCheck then + local result = UI.preCheck(event) + if result ~= nil then + return result + end + end + + if not App.checkCrsfModule() then + UI.handleNoModule() + if App.shouldExit then + return 2 + end + return 0 + end + + crsf.drain(App, App.onFrame) + -- After the drain, so a callback's push is answered before its follow-up + defer.poll() + + UI.render(event, touchState) + + if App.shouldExit then + return 2 + end + return 0 +end + +-- ============================================================================ +-- Return +-- ============================================================================ + +M.init = init +M.run = run +M.useLvgl = useLvgl +return M diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua new file mode 100644 index 0000000..22dc742 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -0,0 +1,361 @@ +---- ######################################################################### +---- # LVGL UI: Color LCD rendering for the bind phrase manager # +---- # For color LCD radios with EdgeTX 2.11.6+/2.12.1+ LVGL support # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local crsf = deps.crsf +local msp = deps.msp + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + -- App.rev the current page was built for; nil forces the first build. + builtRev = nil, + -- Guards the one-shot version/no-module dialogs. + dialogBuilt = false, +} + +-- ============================================================================ +-- EdgeTX version gate +-- ============================================================================ + +local versionCheckResult = nil + +local function checkEdgeTxVersion() + local _ver, _radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor == 12 and rev >= 1 then + return true + elseif maj == 2 and minor == 11 and rev >= 6 then + return true + end + + return false +end + +local function showVersionRequired() + lvgl.clear() + + local dg = lvgl.dialog({ + title = "EdgeTX Version Not Supported", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() + App.shouldExit = true + end, + }) + + dg:build({ + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "Requires EdgeTX:" }, + { type = "label", text = "- 2.11.6 or later" }, + { type = "label", text = "- 2.12.1 or later" }, + { type = "label", text = "- 3.0 or later" }, + }, + }, + { + 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, + }, + }, + }, + }) +end + +local function showNoModule() + lvgl.clear() + + local dg = lvgl.dialog({ + title = "No Module Found: Check Model Settings", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() + App.shouldExit = true + end, + }) + + dg:build({ + { + type = lvgl.BOX, + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = lvgl.LABEL, text = "- Internal/External module enabled" }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, + }, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 98, + text = "Exit", + press = function() + dg:close() + App.shouldExit = true + end, + }, + }, + }, + }) +end + +-- ============================================================================ +-- Main page +-- ============================================================================ + +local function exitTool() + App.shouldExit = true +end + +local function isSetEnabled() + return App.isTargetReachableOrBoth() and App.phrase ~= "" +end + +local function isRxSelected() + return App.target == App.TARGET_RX +end + +local function isRxSelectedConnected() + return App.target == App.TARGET_RX and crsf.hasTelemetry +end + +local function isRxSelectedDisconnected() + return App.target == App.TARGET_RX and not crsf.hasTelemetry +end + +local function buildUi() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS Bind Phrase", + subtitle = App.uidLine, + back = exitTool, + }) + + local tbox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- ***** Bind Phrase label + text edit + Set button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Bind phrase", + children = { + { + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.TEXT_EDIT, + w = 250 * lvgl.LCD_SCALE, + value = App.phrase, + -- A phrase must fit one un-chunked MSP_WRITE frame + length = msp.CONST.PHRASE_MAX, + set = function(v) + App.phrase = v + end, + active = App.isTargetReachableOrBoth, + }, + { + type = lvgl.BUTTON, + text = "Set", + press = App.sendSet, + active = isSetEnabled, + }, + }, + }, + }, + }) + + -- ***** Target label + dropdown + Request UID button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Target", + children = { + { + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.CHOICE, + title = "Select Target", + values = { "Transmitter", "Receiver", "Both" }, + get = function() + return App.target + end, + set = function(n) + App.target = n + end, + }, + { + type = lvgl.BUTTON, + text = "Request UID", + press = App.startUidRequest, + active = App.isTargetReachable, + }, + { + type = lvgl.BUTTON, + text = "Unbind", + press = App.sendUnbind, + visible = isRxSelected, + active = isRxSelectedConnected, + }, + }, + }, + }, + }) + + -- ***** Show Bind button if RX target selected and no RX connected ***** + pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + visible = isRxSelectedDisconnected, + children = { + { + type = lvgl.LABEL, + w = 4 + lvgl.LCD_SCALE * (120 + 250), + text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", + }, + { + type = lvgl.BUTTON, + text = "Bind", + press = App.sendBind, + }, + }, + }) + + -- ***** Bind Phrase History ***** + local histSection = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + visible = function() + return App.history.items[1] ~= nil and App.isTargetReachableOrBoth() + end, + }) + histSection:label({ + text = "Bind Phrase History", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + }) + for i = 1, App.history.MAX do + local row = histSection:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + visible = function() + return App.history.items[i] ~= nil + end, + }) + -- Button containing a history item with its value + row:button({ + w = lvgl.PERCENT_SIZE + 80, + text = function() + return App.history.items[i] or "" + end, + press = function() + App.useHistory(i) + end, + }) + -- Button X to delete an item + row:button({ + text = "X", + textColor = COLOR_THEME_WARNING, + press = function() + App.removeHistory(i) + end, + }) + end +end + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + versionCheckResult = checkEdgeTxVersion() +end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(_event) + if not versionCheckResult then + if not UI.dialogBuilt then + showVersionRequired() + UI.dialogBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + return nil +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + if not UI.dialogBuilt then + showNoModule() + UI.dialogBuilt = true + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +-- Rebuild whenever App.rev moved: TEXT_EDIT's value is a build-time +-- snapshot, so a history fill or the Both flow's target flip only shows +-- through a fresh build. +function UI.render(_event, _touchState) + if UI.builtRev ~= App.rev then + buildUi() + UI.builtRev = App.rev + end +end + +return UI diff --git a/src/SCRIPTS/TOOLS/elrs-bind/main.lua b/src/SCRIPTS/TOOLS/elrs-bind/main.lua deleted file mode 100644 index 65214c7..0000000 --- a/src/SCRIPTS/TOOLS/elrs-bind/main.lua +++ /dev/null @@ -1,483 +0,0 @@ --- TNS|ELRS Bind Manager|TNE - -local CRSF = loadScript("/SCRIPTS/ELRS/crsf.lua")() - -local targetIdx = 1 -- 1 = Transmitter, 2 = Receiver, 3 = Both -local targetBothStep = nil -- If setting Both RX and TX, state of change -local bindPhrase = "" -local uidText = "" - -local MSP_ELRS_RXTX_CONFIG = 45 -local ELRS_RXTX_SUBCMD_UID = 0 -local ELRS_RXTX_SUBCMD_BIND_PHRASE = 1 - -local Defer = { _deferCb = nil } - -function Defer.setTimeout(interval, fn, ctx) - Defer._deferCb = { - start = getTime(), - interval = interval, - fn = fn, - ctx = ctx, - } -end - -function Defer.clear() - Defer._deferCb = nil -end - -function Defer.poll() - if Defer._deferCb == nil then - return - end - - if getTime() - Defer._deferCb.start < Defer._deferCb.interval then - return - end - - -- clear the defer first, if the callback wants to set a new one - local oldcb = Defer._deferCb - Defer._deferCb = nil - oldcb.fn(oldcb.ctx) -end - -local History = { - MAX = 5, - FNAME = "history.txt", - vals = {}, -} - -function History.add(s) - if s == nil or s == "" then - return - end - - -- remove this value from the history list if already there - for idx = #History.vals, 1, -1 do - if History.vals[idx] == s then - table.remove(History.vals, idx) - end - end - - table.insert(History.vals, 1, s) - - while #History.vals > History.MAX do - table.remove(History.vals) - end - - History.save() -end - -function History.remove(idx) - table.remove(History.vals, idx) - History.save() -end - -function History.save() - local f = io.open(History.FNAME, "w") - if f == nil then - return - end - io.write(f, table.concat(History.vals, "\n")) - io.close(f) -end - -function History.load() - local f = io.open(History.FNAME, "r") - if f == nil then - return - end - - History.vals = {} - local all = io.read(f, 64 * History.MAX) - io.close(f) - - if all == nil or all == "" then - return - end - - for line in string.gmatch(all, "[^\n]+") do - History.vals[#History.vals + 1] = line - end - - return History.vals[1] -end - -local function onMspResponse(data) - if - data[1] == CRSF.CONST.ADDRESS_HANDSET - and (data[2] == CRSF.CONST.ADDRESS_RX or data[2] == CRSF.CONST.ADDRESS_TX) - then - local mspCmd = data[5] - - if mspCmd == MSP_ELRS_RXTX_CONFIG and data[6] == ELRS_RXTX_SUBCMD_UID then - Defer.clear() - local rxTx = (data[2] == CRSF.CONST.ADDRESS_RX) and "RX" or "TX" - uidText = - string.format("%s: %d, %d, %d, %d, %d, %d", rxTx, data[7], data[8], data[9], data[10], data[11], data[12]) - end - end -end - -local function isTargetReachable() - return targetIdx == 1 or (targetIdx == 2 and CRSF.isConnected) -end - -local function isTargetReachableOrBoth() - return isTargetReachable() or targetIdx == 3 -end - -local function requestUid() - if not isTargetReachable() then - uidText = "Idle" - return - end - - uidText = "Updating..." - - CRSF.push(CRSF.CONST.FRAMETYPE_MSP_REQ, { - (targetIdx == 1) and CRSF.CONST.ADDRESS_TX or CRSF.CONST.ADDRESS_RX, - CRSF.CONST.ADDRESS_HANDSET, - 0x30, - 0x01, - MSP_ELRS_RXTX_CONFIG, - ELRS_RXTX_SUBCMD_UID, - }) - - -- Retry if no response - Defer.setTimeout(50, requestUid) -end - -local function isValidUidByte(s) - local n = tonumber(s) - -- Must be a number, an integer, and within 0..255 range - return n ~= nil and n == math.floor(n) and n >= 0 and n < 256 -end - -local function uidBytesFromText(text) - -- 1. If text is ONLY numbers, commas, and spaces - if string.match(text, "^[0-9, ]+$") then - local asArray = {} - - -- 2. Split by comma and filter valid bytes (trimming whitespace) - for part in string.gmatch(text, "[^,]+") do - local trimmed = string.match(part, "^%s*(.-)%s*$") - - if isValidUidByte(trimmed) then - asArray[#asArray + 1] = tonumber(trimmed) - else - return nil - end - end - - -- 3. If between 4 and 6 valid bytes, left-pad with 0s up to 6 - if #asArray >= 4 and #asArray <= 6 then - local padded = {} - local padCount = 6 - #asArray - - -- Push leading zeroes - for i = 1, padCount do - padded[#padded + 1] = 0 - end - - -- Push existing bytes - for i = 1, #asArray do - padded[#padded + 1] = asArray[i] - end - - return padded - end - end - - return nil -end - -local function sendBindinfoPacket(targetAddr) - -- Test if the string is a UID, and if so use the UID else use the string bindphrase - local uidBytes = uidBytesFromText(bindPhrase) - local mspPayloadLen = uidBytes and #uidBytes or #bindPhrase - local subcmd = uidBytes and ELRS_RXTX_SUBCMD_UID or ELRS_RXTX_SUBCMD_BIND_PHRASE - - local data = { - targetAddr, - CRSF.CONST.ADDRESS_HANDSET, - 0x30, - 0x01 + mspPayloadLen, - MSP_ELRS_RXTX_CONFIG, - subcmd, - } - - if uidBytes then - -- append the UID as bytes - for i = 1, #uidBytes do - data[#data + 1] = uidBytes[i] - end - else - -- append the phrase characters as bytes - for i = 1, #bindPhrase do - data[#data + 1] = string.byte(bindPhrase, i) - end - end - - CRSF.push(CRSF.CONST.FRAMETYPE_MSP_WRITE, data) -end - -local function requestSendBindphrase() - if bindPhrase == "" then - return - end - - -- BothStep state machine - if targetIdx == 3 then - if targetBothStep == nil then - -- Send to RX - targetBothStep = 2 - elseif targetBothStep == 2 then - -- Stop both, send to TX, and change the select to TX - targetBothStep = nil - targetIdx = 1 - end - end - - local effectiveTargetIdx = targetBothStep or targetIdx - if targetBothStep == nil then - local rxTx = (targetIdx == 1) and "Transmitter" or "Receiver" - uidText = "Setting " .. rxTx .. "..." - else - uidText = "Setting RX and disconnecting..." - end - - sendBindinfoPacket((effectiveTargetIdx == 1) and CRSF.CONST.ADDRESS_TX or CRSF.CONST.ADDRESS_RX) - - History.add(bindPhrase) - if targetBothStep == nil then - -- refresh the UID in 1000ms - Defer.setTimeout(100, requestUid) - else - -- Perform the next step of setBindphrase() for Both - Defer.setTimeout(100, requestSendBindphrase) - end -end - -local function sendBindTx() - uidText = "Sending bind command..." - CRSF.sendBind(CRSF.CONST.ADDRESS_TX) - Defer.setTimeout(100, function() - uidText = "Sent" - end) -end - -local function sendBindRx() - uidText = "Sending unbind to RX..." - CRSF.sendBind(CRSF.CONST.ADDRESS_RX) - Defer.setTimeout(100, function() - uidText = "Sent" - end) -end - -local rebuildUi -local function history_text(id) - return History.vals[id] -end -local function history_visible(id) - return history_text(id) ~= nil -end -local function history_press(id) - bindPhrase = History.vals[id] - rebuildUi() -end -local function history_remove(id) - History.remove(id) -end - -rebuildUi = function() - lvgl.clear() - - local pg = lvgl.page({ - title = "ExpressLRS Bind Phrase", - subtitle = function() - return uidText - end, - }) - - local tbox = pg:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_COLUMN, - }) - - -- ***** Bind Phrase label + text edit + Set button ***** - tbox:setting({ - w = lvgl.PERCENT_SIZE + 100, - title = "Bind phrase", - children = { - { - type = lvgl.BOX, - x = 120 * lvgl.LCD_SCALE, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - children = { - { - type = lvgl.TEXT_EDIT, - w = 250 * lvgl.LCD_SCALE, - value = bindPhrase, - length = 52, -- packet is only so big and can't span - set = function(v) - bindPhrase = v - end, - active = isTargetReachableOrBoth, - }, - { - type = lvgl.BUTTON, - text = "Set", - press = requestSendBindphrase, - active = function() - return isTargetReachableOrBoth() and bindPhrase ~= "" - end, - }, - }, - }, - }, - }) - - -- ***** Target label + dropdown + Request UID button ***** - tbox:setting({ - w = lvgl.PERCENT_SIZE + 100, - title = "Target", - children = { - { - type = lvgl.BOX, - x = 120 * lvgl.LCD_SCALE, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - children = { - { - type = lvgl.CHOICE, - title = "Select Target", - values = { "Transmitter", "Receiver", "Both" }, - get = function() - return targetIdx - end, - set = function(n) - targetIdx = n - end, - }, - { - type = lvgl.BUTTON, - text = "Request UID", - press = requestUid, - active = isTargetReachable, - }, - { - type = lvgl.BUTTON, - text = "Unbind", - press = sendBindRx, - -- Visible if RX - visible = function() - return targetIdx == 2 - end, - -- Active if RX and is connected - active = function() - return targetIdx == 2 and CRSF.isConnected - end, - }, - }, - }, - }, - }) - - -- ***** Show Bind button if RX target selected and no RX connected ***** - pg:box({ - w = lvgl.PERCENT_SIZE + 100, - y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - align = LEFT, - visible = function() - return targetIdx == 2 and not CRSF.isConnected - end, - children = { - { - type = lvgl.LABEL, - w = 4 + lvgl.LCD_SCALE * (120 + 250), - text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", - }, - { - type = lvgl.BUTTON, - text = "Bind", - press = sendBindTx, - }, - }, - }) - - -- ***** Bind Phrase History ***** - local histSection = pg:box({ - w = lvgl.PERCENT_SIZE + 100, - y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = 0, - -- visible if there is history and TX selected or RX selected and isConnected - visible = function() - return #History.vals > 0 and isTargetReachableOrBoth() - end, - }) - histSection:label({ - text = "Bind Phrase History", - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - }) - for i = 1, History.MAX do - local row = histSection:box({ - w = lvgl.PERCENT_SIZE + 100, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_SMALL, - visible = function() - return history_visible(i) - end, - }) - -- Button containing a history item with its value - row:button({ - w = lvgl.PERCENT_SIZE + 80, - text = function() - return history_text(i) or "" - end, - press = function() - return history_press(i) - end, - }) - -- Button X to delete an item - row:button({ - text = "X", - textColor = COLOR_THEME_WARNING, - press = function() - return history_remove(i) - end, - }) - end -end - -local function init() - if lvgl == nil then - return - end - - bindPhrase = History.load() or "" - rebuildUi() - - CRSF:registerHandler(CRSF.CONST.FRAMETYPE_MSP_RESP, onMspResponse) - Defer.setTimeout(1, requestUid) -end - -local function run(event, touchState) - if lvgl == nil then - lcd.drawText(0, 0, "LVGL (EdgeTX 2.11+) required", COLOR_THEME_WARNING) - return 0 - end - - CRSF:poll() - -- Must come after poll so a telemetry queue is established - Defer.poll() - - return 0 -end - -return { init = init, run = run, useLvgl = true } From 7c7a60aeb133f0b7a8d4007d0cb2966016737593 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:27:24 +0300 Subject: [PATCH 178/218] Replicate the firmware's B&W name editor as a shared component --- src/SCRIPTS/ELRS/ui/lcd/text_edit.lua | 182 ++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/SCRIPTS/ELRS/ui/lcd/text_edit.lua diff --git a/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua b/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua new file mode 100644 index 0000000..444c8f1 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua @@ -0,0 +1,182 @@ +--------------------------------------------------------------------------- +-- B&W Text Editor -- +-- Loaded via loadScript() with no arguments; returns the TextEdit -- +-- table. Construct one instance per editable field: TextEdit.new(). -- +-- -- +-- A Lua port of the firmware's editName() (gui/common/stdlcd/ -- +-- draw_functions.cpp:178), so editing a string in a tool feels exactly -- +-- like editing a model name: the cursor starts on the first char, -- +-- rotary cycles that char through the name charset (clamped at the -- +-- ends, case preserved), ENTER advances the cursor, ENTER on the last -- +-- cell or long ENTER on a space commits, and long ENTER on a letter -- +-- toggles its case. EXIT also commits -- edits are applied to the -- +-- value as they are made, never reverted. Trailing spaces are stripped -- +-- on commit; deletion is overwriting with spaces. -- +--------------------------------------------------------------------------- + +local TextEdit = {} +TextEdit.__index = TextEdit + +-- The firmware's nameChars. Digits and the comma are in it, so a raw UID +-- ("0,1,2,3,4,5") can be entered with the same editor. +local CHARS = " abcdefghijklmnopqrstuvwxyz0123456789_-,." + +--- Index of a char in CHARS, upper-case letters mapping like their +-- lower-case form (nameCharIdx in the firmware). Unknown chars map to +-- the leading space. +local function charIdx(c) + local b = string.byte(c) + if b >= 65 and b <= 90 then + c = string.char(b + 32) + end + local idx = string.find(CHARS, c, 1, true) + -- "." is no pattern here thanks to the plain flag; the charset has no + -- other metachars. + return idx or 1 +end + +local function isUpper(c) + local b = string.byte(c) + return b ~= nil and b >= 65 and b <= 90 +end + +local function isLower(c) + local b = string.byte(c) + return b ~= nil and b >= 97 and b <= 122 +end + +--- Construct an editor instance. +-- @param maxLen maximum value length in chars +-- @param visible chars that fit the row; longer values draw a window +-- ending at the cursor +function TextEdit.new(maxLen, visible) + return setmetatable({ + maxLen = maxLen, + visible = visible, + value = "", + editing = nil, + cur = 1, + }, TextEdit) +end + +--- Enter edit mode with the cursor on the first char. +function TextEdit:start() + self.editing = true + self.cur = 1 +end + +--- Replace the char under the cursor, padding with spaces when the +-- cursor sits past the end of the value. +function TextEdit:_setChar(c) + local v = self.value + while #v < self.cur - 1 do + v = v .. " " + end + self.value = string.sub(v, 1, self.cur - 1) .. c .. string.sub(v, self.cur + 1) +end + +function TextEdit:_charAt(pos) + local c = string.sub(self.value, pos, pos) + if c == "" then + return " " + end + return c +end + +function TextEdit:_commit() + self.editing = nil + local v = self.value + local last = #v + while last > 0 and string.byte(v, last) == 32 do + last = last - 1 + end + self.value = string.sub(v, 1, last) +end + +--- Handle one event while editing. Returns true when the edit committed +-- on this event, nil while it continues. +function TextEdit:handleEvent(event) + local c = self:_charAt(self.cur) + + if event == EVT_VIRTUAL_NEXT or event == EVT_VIRTUAL_PREV then + local idx = charIdx(c) + if event == EVT_VIRTUAL_NEXT then + idx = math.min(idx + 1, #CHARS) + else + idx = math.max(idx - 1, 1) + end + local nc = string.sub(CHARS, idx, idx) + if isUpper(c) and isLower(nc) then + nc = string.char(string.byte(nc) - 32) + end + self:_setChar(nc) + return nil + end + + if event == EVT_VIRTUAL_ENTER then + if self.cur < self.maxLen then + self.cur = self.cur + 1 + else + self:_commit() + return true + end + return nil + end + + if event == EVT_VIRTUAL_ENTER_LONG then + killEvents(event) + if c == " " then + self:_commit() + return true + elseif isUpper(c) then + self:_setChar(string.char(string.byte(c) + 32)) + elseif isLower(c) then + self:_setChar(string.char(string.byte(c) - 32)) + end + return nil + end + + if event == EVT_VIRTUAL_EXIT then + self:_commit() + return true + end + + return nil +end + +--- Draw the value at (x, y). attr is the row's base attribute (INVERS on +-- the selected row); while editing, only the cursor cell is inverted, +-- like the firmware's edit rendering. +function TextEdit:draw(x, y, attr) + if not self.editing then + local shown = self.value + if shown == "" then + shown = "---" + elseif self.visible and #shown > self.visible then + shown = string.sub(shown, 1, self.visible) + end + lcd.drawText(x, y, shown, attr) + return + end + + -- Window the value so the cursor is always on screen + local first = 1 + if self.visible and self.cur > self.visible then + first = self.cur - self.visible + 1 + end + local prefix = string.sub(self.value, first, self.cur - 1) + local suffix = "" + if self.visible then + suffix = string.sub(self.value, self.cur + 1, first + self.visible - 1) + else + suffix = string.sub(self.value, self.cur + 1) + end + + lcd.drawText(x, y, prefix) + lcd.drawText(lcd.getLastPos(), y, self:_charAt(self.cur), INVERS) + if suffix ~= "" then + lcd.drawText(lcd.getLastPos(), y, suffix) + end +end + +return TextEdit From e5c79c9af195417a296c8cf90074f763834e07c2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:27:24 +0300 Subject: [PATCH 179/218] Add a B&W interface to the bind tool --- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua | 380 ++++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua new file mode 100644 index 0000000..aad4e45 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -0,0 +1,380 @@ +---- ######################################################################### +---- # BW LCD UI: Rendering, input handling, cursor management # +---- # For black & white radios (no LVGL required) # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local crsf = deps.crsf +local msp = deps.msp +local VERSION = deps.VERSION + +local TextEdit = loadScript("/SCRIPTS/ELRS/ui/lcd/text_edit.lua")() + +local versionCheckResult = nil + +local function checkEdgeTxVersion() + local _ver, _radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor == 12 and rev >= 1 then + return true + elseif maj == 2 and minor == 11 and rev >= 6 then + return true + end + + return false +end + +-- ============================================================================ +-- UI state +-- ============================================================================ + +-- Row ids (numeric to save RAM). History items are encoded as HIST_BASE + i. +local ROW_PHRASE = 1 +local ROW_TARGET = 2 +local ROW_UID = 3 +local ROW_SET = 4 +local ROW_BIND = 5 -- renders Bind or Unbind depending on the link +local ROW_HISTORY = 6 +local ROW_EXIT = 7 +local ROW_CLEAR = 8 +local ROW_BACK = 9 +local HIST_BASE = 100 + +local PAGE_MAIN = 1 +local PAGE_HISTORY = 2 + +-- Short forms: "Transmitter" would truncate at COL2 on 128 px +local TARGET_NAMES = { "TX", "RX", "Both" } + +local UI = { + -- Cursor/selection state (owned entirely by this module) + page = PAGE_MAIN, + lineIndex = 1, + rows = {}, + editTarget = nil, + ---@type table constructed in init(), needs LCD_W for the window + phraseEdit = nil, + + -- Pending confirmation popup: { msg, i (history index) or nil = clear all } + confirm = nil, + + -- Layout constants for 128x64; UI.init widens COL2 at 212px wide + COL1 = 0, + COL2 = 70, + textSize = 8, + textYoffset = 3, + + -- Redraw state: the page repaints on events and whenever the live status + -- line changes (statusLine below), tracked as a string compare per frame. + forceRedraw = true, + lastStatus = nil, +} + +-- Both pages fit the 8-row 128x64 grid (main 7 rows, history at most 7), +-- so there is no scrolling and no page offset in this UI. + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + end + + -- The phrase window ends at the cursor; 6 px per char of the fixed BW font + UI.phraseEdit = TextEdit.new(msp.CONST.PHRASE_MAX, math.floor((LCD_W - UI.COL2 - 2) / 6)) + UI.phraseEdit.value = App.phrase + + versionCheckResult = checkEdgeTxVersion() +end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(event) + if not versionCheckResult then + UI.drawAlert("Unsupported", { + "Requires EdgeTX:", + "- 2.11.6 or later", + "- 2.12.1 or later", + "- 3.0 or later", + }) + if event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return 2 + end + return 0 + end + return nil +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + UI.drawAlert(" No ExpressLRS", { + "Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + "also set Internal RF to", + "CRSF in SYS->Hardware", + }) +end + +-- ============================================================================ +-- Alert screen (clear screen + title + body messages) +-- ============================================================================ + +function UI.drawAlert(title, msgs) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (UI.textSize * 2) - 2 + for _, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + UI.textSize + end +end + +-- ============================================================================ +-- Row list +-- ============================================================================ + +local function buildRows() + local rows = UI.rows + for i = #rows, 1, -1 do + rows[i] = nil + end + if UI.page == PAGE_MAIN then + rows[1] = ROW_PHRASE + rows[2] = ROW_TARGET + rows[3] = ROW_UID + rows[4] = ROW_SET + if App.target == App.TARGET_RX then + rows[5] = ROW_BIND + end + rows[#rows + 1] = ROW_HISTORY + rows[#rows + 1] = ROW_EXIT + else + for i = 1, #App.history.items do + rows[#rows + 1] = HIST_BASE + i + end + rows[#rows + 1] = ROW_CLEAR + rows[#rows + 1] = ROW_BACK + end + if UI.lineIndex > #rows then + UI.lineIndex = #rows + end +end + +--- The UID/status row text: compact so it fits 128 px at SMLSIZE. +local function statusLine() + if App.statusText then + return App.statusText + end + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d,%d,%d,%d,%d,%d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) +end + +-- ============================================================================ +-- Navigation and actions +-- ============================================================================ + +local function selectRow(step) + local count = #UI.rows + local idx = UI.lineIndex + step + if idx < 1 then + idx = count + elseif idx > count then + idx = 1 + end + UI.lineIndex = idx +end + +local function goToPage(page) + UI.page = page + UI.lineIndex = 1 + UI.forceRedraw = true +end + +local function handleEnter(id) + if id == ROW_PHRASE then + if App.isTargetReachableOrBoth() then + UI.phraseEdit.value = App.phrase + UI.phraseEdit:start() + end + elseif id == ROW_TARGET then + UI.editTarget = true + elseif id == ROW_UID then + App.startUidRequest() + elseif id == ROW_SET then + if App.isTargetReachableOrBoth() and App.phrase ~= "" then + App.sendSet() + end + elseif id == ROW_BIND then + if crsf.hasTelemetry then + App.sendUnbind() + else + App.sendBind() + end + elseif id == ROW_HISTORY then + goToPage(PAGE_HISTORY) + elseif id == ROW_EXIT then + App.shouldExit = true + elseif id == ROW_CLEAR then + UI.confirm = { msg = "Clear history?" } + elseif id == ROW_BACK then + goToPage(PAGE_MAIN) + elseif id > HIST_BASE then + App.useHistory(id - HIST_BASE) + UI.phraseEdit.value = App.phrase + goToPage(PAGE_MAIN) + end +end + +local function handleEvent(event) + -- Phrase editing captures every event until it commits + if UI.phraseEdit.editing then + if UI.phraseEdit:handleEvent(event) then + App.phrase = UI.phraseEdit.value + end + return + end + + -- Target edit: rotary cycles, ENTER or EXIT commits + if UI.editTarget then + if event == EVT_VIRTUAL_NEXT then + App.target = math.min(App.target + 1, App.TARGET_BOTH) + elseif event == EVT_VIRTUAL_PREV then + App.target = math.max(App.target - 1, App.TARGET_TX) + elseif event == EVT_VIRTUAL_ENTER or event == EVT_VIRTUAL_EXIT then + UI.editTarget = nil + end + return + end + + if event == EVT_VIRTUAL_EXIT then + if UI.page == PAGE_HISTORY then + goToPage(PAGE_MAIN) + else + App.shouldExit = true + end + elseif event == EVT_VIRTUAL_ENTER then + handleEnter(UI.rows[UI.lineIndex]) + elseif event == EVT_VIRTUAL_ENTER_LONG then + killEvents(event) + local id = UI.rows[UI.lineIndex] + if id and id > HIST_BASE then + UI.confirm = { msg = "Delete entry?", i = id - HIST_BASE } + end + elseif event == EVT_VIRTUAL_NEXT then + selectRow(1) + elseif event == EVT_VIRTUAL_PREV then + selectRow(-1) + end +end + +-- ============================================================================ +-- Rendering +-- ============================================================================ + +local function drawTitle() + lcd.drawFilledRectangle(0, 0, LCD_W, UI.textSize + 1, GREY_DEFAULT) + lcd.drawText(UI.COL1 + 1, 1, "ELRS Bind " .. VERSION, INVERS) + -- Link flag: C while RX telemetry is alive, - otherwise + lcd.drawText(LCD_W - 1, 1, crsf.hasTelemetry and "C" or "-", INVERS + RIGHT) +end + +local function drawRow(id, yPos, isSelected) + local attr = isSelected and INVERS or 0 + if id == ROW_PHRASE then + lcd.drawText(UI.COL1, yPos, "Phrase", 0) + UI.phraseEdit:draw(UI.COL2, yPos, attr) + elseif id == ROW_TARGET then + if UI.editTarget and isSelected then + attr = attr + BLINK + end + lcd.drawText(UI.COL1, yPos, "Target", 0) + lcd.drawText(UI.COL2, yPos, TARGET_NAMES[App.target], attr) + elseif id == ROW_UID then + lcd.drawText(UI.COL1, yPos, statusLine(), attr + SMLSIZE) + elseif id == ROW_SET then + lcd.drawText(10, yPos, "[Set]", attr + BOLD) + elseif id == ROW_BIND then + lcd.drawText(10, yPos, crsf.hasTelemetry and "[Unbind]" or "[Bind]", attr + BOLD) + elseif id == ROW_HISTORY then + lcd.drawText(UI.COL1, yPos, "> History", attr + BOLD) + elseif id == ROW_EXIT then + lcd.drawText(10, yPos, "[---- EXIT ----]", attr + BOLD) + elseif id == ROW_CLEAR then + lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) + elseif id == ROW_BACK then + lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) + elseif id > HIST_BASE then + lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) + end +end + +local function drawPage(event) + handleEvent(event) + buildRows() + + lcd.clear() + drawTitle() + + for i = 1, #UI.rows do + drawRow(UI.rows[i], i * UI.textSize + UI.textYoffset, UI.lineIndex == i) + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, _touchState) + -- Pending confirmation owns the screen until answered. It arms only after + -- a quiet frame: the ENTER release that follows the long press which + -- opened it would otherwise answer it on the spot. + if UI.confirm then + local result = popupConfirmation(UI.confirm.msg, "PRESS [OK] to confirm", event) + if not UI.confirm.armed then + if event == 0 then + UI.confirm.armed = true + end + return + end + if result == "OK" then + if UI.confirm.i then + App.removeHistory(UI.confirm.i) + else + App.clearHistory() + end + UI.confirm = nil + UI.forceRedraw = true + elseif result == "CANCEL" then + UI.confirm = nil + UI.forceRedraw = true + end + return + end + + -- Repaint on any event, while editing, and whenever the live parts of the + -- page (status line, link flag) changed since the last paint. + local status = statusLine() .. (crsf.hasTelemetry and "C" or "-") + if event ~= 0 or UI.forceRedraw or UI.phraseEdit.editing or status ~= UI.lastStatus then + drawPage(event) + UI.lastStatus = status + UI.forceRedraw = false + end +end + +return UI From 3c52fa584fc7daba68896ed06e4d200bd6ad32b5 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:27:24 +0300 Subject: [PATCH 180/218] Document the bind tool and its MSP modules --- README.md | 32 ++++++++++++++++++++++++-- docs/development.md | 43 ++++++++++++++++++++++++++++++----- edgetx.yml | 2 ++ screenshots/tool_bind.png | Bin 0 -> 25261 bytes screenshots/tool_bind_bw.png | Bin 0 -> 4138 bytes 5 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 screenshots/tool_bind.png create mode 100644 screenshots/tool_bind_bw.png diff --git a/README.md b/README.md index 5edda75..e833698 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ExpressLRS Lua Scripts -Lua configuration tool for ExpressLRS on EdgeTX radios. Works on both black & white LCD and color LCD radios. +Lua configuration tool and bind phrase manager for ExpressLRS on EdgeTX radios. Both work on black & white LCD and color LCD radios. The package also includes two color-LCD widgets: the **ELRS Telemetry Widget** and the **VTX Administrator Widget**. @@ -22,6 +22,13 @@ SCRIPTS/ crsf_params.lua -- parameter codec (tool, VTX Admin) crsf_session.lua -- stateful parameter client (tool, VTX Admin) crsf_elrsinfo.lua -- TX module info state (telemetry widget) + msp.lua -- MSP-over-CRSF codec (bind tool) + defer.lua -- deferred-callback timer (bind tool) + ui/ + lcd/ + text_edit.lua -- BW text editor (bind tool) + sensors.lua -- telemetry sensor reader + file_storage.lua -- key=value file persistence shim.lua -- BW compatibility shim TOOLS/ ExpressLRS/ @@ -30,6 +37,12 @@ SCRIPTS/ ui/ lvgl.lua -- color LCD UI (LVGL) lcd.lua -- black & white LCD UI + ExpressLRSBind/ + main.lua -- entry point + history_storage.lua -- bind phrase history persistence + ui/ + lvgl.lua -- color LCD UI (LVGL) + lcd.lua -- black & white LCD UI WIDGETS/ ELRSTelemetry/ main.lua @@ -44,7 +57,7 @@ WIDGETS/ ... ``` -The shared library `SCRIPTS/ELRS/` is required by the configuration tool and both widgets. +The shared library `SCRIPTS/ELRS/` is required by both tools and both widgets. ### Install with edgetx-cli @@ -64,6 +77,18 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t ExpressLRS Configuration Tool +## ExpressLRS Bind Phrase Manager + +The bind tool (`SCRIPTS/TOOLS/ExpressLRSBind/`) sets the bind phrase -- or a raw UID entered as +comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, reads the +current UID back for verification, and can put the TX in bind mode or unbind a connected receiver. +The last five phrases are kept as a pick-and-send history. Setting the phrase over MSP requires +**ExpressLRS 4.1+** on the device. + +ExpressLRS Bind Phrase Manager
+ +ExpressLRS Bind Phrase Manager + ## Widgets Both widgets running side-by-side on the home screen: @@ -92,3 +117,6 @@ See [docs/development.md](docs/development.md) for the tool's internal architect |------------|----------|------------| | Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ | | Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ | + +The bind phrase manager additionally requires **ExpressLRS 4.1+** on the device for its MSP +configuration support; on older firmware it reports "No response (needs ELRS 4.1+)". diff --git a/docs/development.md b/docs/development.md index 12bed04..245af8a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,6 +1,6 @@ # Development -This document covers the internal architecture of the ExpressLRS configuration tool and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware. +This document covers the internal architecture of the ExpressLRS tools and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware. ## Make targets @@ -32,6 +32,8 @@ Run `make help` to list all targets. The Makefile groups them into three categor ## Architecture +The configuration tool, `SCRIPTS/TOOLS/ExpressLRS/`: + | Module | Purpose | |--------|---------| | `main.lua` | Entry point, run-loop orchestrator, and the App policy layer (device switching, folder-ready edges, the synthetic "Other Devices" row types) over a `crsf_session.lua` instance | @@ -39,7 +41,21 @@ Run `make help` to list all targets. The Makefile groups them into three categor | `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | | `ui/lcd.lua` | BW LCD interface (text cursor, popups) | -The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too: +The bind phrase manager, `SCRIPTS/TOOLS/ExpressLRSBind/` (sets the bind phrase / UID over MSP; +the device side requires ExpressLRS 4.1+, and a pre-4.1 device simply never answers -- the tool's +bounded UID retry reports that instead of polling forever): + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded UID read retry, and the frame router over `crsf.drain` | +| `history_storage.lua` | The last five phrases, newest first, persisted through `file_storage.lua` as indexed keys `h1`..`h5` | +| `ui/lvgl.lua` | Color LCD interface | +| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`) | + +Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one +of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds. + +The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too: | Module | Purpose | |--------|---------| @@ -47,15 +63,19 @@ The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | +| `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | +| `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | +| `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | | `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | -| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded only by the VTX Admin widget | +| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | | `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and the firmware delivers every widget instance its own copy of each incoming frame, so **each script -instance has exactly one draining consumer**: the tool and the VTX Admin widget drain through -`session:drain()`, the telemetry widget through `elrsinfo:drain()`. A future widget needing two -consumers must pop once and route the frames itself. `reassemble()` callers pass the field id they +instance has exactly one draining consumer**: the config tool and the VTX Admin widget drain through +`session:drain()`, the telemetry widget through `elrsinfo:drain()`, and the bind tool through +`crsf.drain(App, App.onFrame)`. A future widget needing two consumers must pop once and route the +frames itself. `reassemble()` callers pass the field id they are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`, used by VTX Admin so sibling instances stay in sync from each other's answers). @@ -96,6 +116,17 @@ The simulator supports multiple test scenarios, configurable via the `config.sce | `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | | `critical_error` | TX + RX connected with a critical baud-rate error flag. Triggers the warning screen; the suppress write clears it. | +### MSP bind traffic + +The mock answers the bind tool's MSP `RXTX_CONFIG` traffic: a UID read (`MSP_REQ`) is served from a +per-device `mspUid` table -- the TX and RX deliberately start with different UIDs so a fresh `normal` +run shows a mismatch that setting both to one phrase visibly fixes -- and a phrase write +(`MSP_WRITE`) rederives the target's UID through a deterministic pseudo-hash, so equal phrases give +equal UIDs (the property the Both flow demonstrates; the bytes need not match the firmware's MD5). +The RX answers only while the scenario keeps it reachable, which is what exercises the tool's +bounded retry, and writes are unacknowledged just like the real firmware. `FRAMETYPE_COMMAND` +bind/unbind requests are log-only. + `config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as `CRSFEndpoint::sendParameter` does, so lowering it -- real firmware shrinks it on slow baud rates in diff --git a/edgetx.yml b/edgetx.yml index 3e5f696..0420184 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -13,6 +13,8 @@ package: screenshots: - screenshots/tool_main.png - screenshots/tool_main_bw.png + - screenshots/tool_bind.png + - screenshots/tool_bind_bw.png - screenshots/widget_telemetry_fullscren.png - screenshots/widget_vtxadmin_fullscreen.png - screenshots/widgets.png diff --git a/screenshots/tool_bind.png b/screenshots/tool_bind.png new file mode 100644 index 0000000000000000000000000000000000000000..5083b31e7eb9c018ce2bd406c779b013e5935859 GIT binary patch literal 25261 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 Date: Fri, 14 Aug 2026 08:33:07 +0300 Subject: [PATCH 181/218] Extract the tools' script loader into the ELRS library --- README.md | 1 + docs/development.md | 1 + src/SCRIPTS/ELRS/loader.lua | 27 +++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 27 ++++++++------------- src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua | 29 +++++++++-------------- 5 files changed, 50 insertions(+), 35 deletions(-) create mode 100644 src/SCRIPTS/ELRS/loader.lua diff --git a/README.md b/README.md index e833698..a332cfe 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ SCRIPTS/ crsf_elrsinfo.lua -- TX module info state (telemetry widget) msp.lua -- MSP-over-CRSF codec (bind tool) defer.lua -- deferred-callback timer (bind tool) + loader.lua -- GC-guarded script loader (both tools) ui/ lcd/ text_edit.lua -- BW text editor (bind tool) diff --git a/docs/development.md b/docs/development.md index 245af8a..2b274ed 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | +| `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | | `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | | `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | | `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | diff --git a/src/SCRIPTS/ELRS/loader.lua b/src/SCRIPTS/ELRS/loader.lua new file mode 100644 index 0000000..b4c7cad --- /dev/null +++ b/src/SCRIPTS/ELRS/loader.lua @@ -0,0 +1,27 @@ +--------------------------------------------------------------------------- +-- GC-Guarded Script Loader -- +-- Loaded via loadScript() with no arguments; returns the loader -- +-- function. The one library part that cannot load itself: consumers -- +-- bootstrap it with a bare loadScript call. -- +-- -- +-- A full collection before each load frees the previous compile's -- +-- parser scratch; the firmware runs no GC between loadScript calls, so -- +-- on a fresh install (no .luac yet) the compile peaks would otherwise -- +-- stack -- the cause of the B&W out-of-memory on a tool's first launch. -- +--------------------------------------------------------------------------- + +--- Load a script chunk and run it, passing the remaining arguments +-- through. Raises with the path when the script is missing or fails to +-- compile, which names the culprit in the firmware's error screen. +-- @param path absolute SD path +-- @return the chunk's return value +local function loader(path, ...) + collectgarbage("collect") + local chunk = loadScript(path) + if chunk == nil then + error(path) + end + return chunk(...) +end + +return loader diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index ecf538b..a8d20f2 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -15,22 +15,15 @@ local useLvgl = (lvgl ~= nil) -- Load shared modules -- ============================================================================ --- A full collection before each load frees the previous compile's parser --- scratch; the firmware runs no GC between loadScript calls, so on a fresh --- install (no .luac yet) the compile peaks would otherwise stack. -local function loadPart(path, arg1, arg2) - collectgarbage("collect") - local chunk = loadScript(path) - if chunk == nil then - error(path) - end - return chunk(arg1, arg2) -end +-- The loader is the one shared part that must bootstrap with a bare +-- loadScript; it owns the GC-before-load discipline. +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() -local crsf = loadPart("/SCRIPTS/ELRS/crsf.lua") -local params = loadPart("/SCRIPTS/ELRS/crsf_params.lua", crsf) -local CRSFSession = loadPart("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) -local Navigation = loadPart("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua") +local crsf = loader("/SCRIPTS/ELRS/crsf.lua") +local params = loader("/SCRIPTS/ELRS/crsf_params.lua", crsf) +local CRSFSession = loader("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) +local Navigation = loader("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua") -- ============================================================================ -- App Module: business logic between the session and the UI @@ -145,9 +138,9 @@ local function init() VERSION = VERSION, } if useLvgl then - UI = loadPart("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua", deps) + UI = loader("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua", deps) else - UI = loadPart("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua", deps) + UI = loader("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua", deps) end UI.init() -- The returned table stays on the standalone Lua stack and pins init(), diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua index 2bb81b6..4acbfb5 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -15,23 +15,16 @@ local useLvgl = (lvgl ~= nil) -- Load shared modules -- ============================================================================ --- A full collection before each load frees the previous compile's parser --- scratch; the firmware runs no GC between loadScript calls, so on a fresh --- install (no .luac yet) the compile peaks would otherwise stack. -local function loadPart(path, arg1) - collectgarbage("collect") - local chunk = loadScript(path) - if chunk == nil then - error(path) - end - return chunk(arg1) -end +-- The loader is the one shared part that must bootstrap with a bare +-- loadScript; it owns the GC-before-load discipline. +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() -local crsf = loadPart("/SCRIPTS/ELRS/crsf.lua") -local msp = loadPart("/SCRIPTS/ELRS/msp.lua", crsf) -local defer = loadPart("/SCRIPTS/ELRS/defer.lua") -local FileStorage = loadPart("/SCRIPTS/ELRS/file_storage.lua") -local History = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua", FileStorage) +local crsf = loader("/SCRIPTS/ELRS/crsf.lua") +local msp = loader("/SCRIPTS/ELRS/msp.lua", crsf) +local defer = loader("/SCRIPTS/ELRS/defer.lua") +local FileStorage = loader("/SCRIPTS/ELRS/file_storage.lua") +local History = loader("/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua", FileStorage) -- ============================================================================ -- App Module: business logic shared by both UI frontends @@ -289,9 +282,9 @@ local function init() } App.phrase = History.items[1] or "" if useLvgl then - UI = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua", deps) + UI = loader("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua", deps) else - UI = loadPart("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua", deps) + UI = loader("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua", deps) end UI.init() -- One tick of delay so run()'s first drain creates the telemetry queue From a7993bb48c37770e8356f8d10c23f4a0df6d50ad Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:38:08 +0300 Subject: [PATCH 182/218] Move the EdgeTX version gate into the shared library --- README.md | 1 + docs/development.md | 1 + src/SCRIPTS/ELRS/edgetx_version.lua | 28 ++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/main.lua | 2 ++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 20 +------------- src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 22 ++------------- src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua | 2 ++ src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua | 20 +------------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua | 22 ++------------- 9 files changed, 40 insertions(+), 78 deletions(-) create mode 100644 src/SCRIPTS/ELRS/edgetx_version.lua diff --git a/README.md b/README.md index a332cfe..94160aa 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ SCRIPTS/ msp.lua -- MSP-over-CRSF codec (bind tool) defer.lua -- deferred-callback timer (bind tool) loader.lua -- GC-guarded script loader (both tools) + edgetx_version.lua -- EdgeTX minimum-version gate (both tools) ui/ lcd/ text_edit.lua -- BW text editor (bind tool) diff --git a/docs/development.md b/docs/development.md index 2b274ed..e7dd929 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,6 +67,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | | `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | +| `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | | `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | | `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | | `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | diff --git a/src/SCRIPTS/ELRS/edgetx_version.lua b/src/SCRIPTS/ELRS/edgetx_version.lua new file mode 100644 index 0000000..f90e0a9 --- /dev/null +++ b/src/SCRIPTS/ELRS/edgetx_version.lua @@ -0,0 +1,28 @@ +--------------------------------------------------------------------------- +-- EdgeTX Version Gate -- +-- Loaded via loadScript() with no arguments; returns the isSupported -- +-- function. -- +-- -- +-- The one home of the package's minimum EdgeTX requirement. The tools -- +-- check it once at load and hand the result to their UI chunk, whose -- +-- preCheck owns the presentation. Keep the manifest's -- +-- min_edgetx_version (edgetx.yml) in step with this ladder. -- +--------------------------------------------------------------------------- + +--- True when the running firmware meets the minimum: 2.11.6, 2.12.1 +-- or 3.0. +local function isSupported() + local _ver, _radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor == 12 and rev >= 1 then + return true + elseif maj == 2 and minor == 11 and rev >= 6 then + return true + end + + return false +end + +return isSupported diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua index a8d20f2..2e6cd19 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -24,6 +24,7 @@ local crsf = loader("/SCRIPTS/ELRS/crsf.lua") local params = loader("/SCRIPTS/ELRS/crsf_params.lua", crsf) local CRSFSession = loader("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) local Navigation = loader("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua") +local versionOk = loader("/SCRIPTS/ELRS/edgetx_version.lua")() -- ============================================================================ -- App Module: business logic between the session and the UI @@ -136,6 +137,7 @@ local function init() session = session, crsf = crsf, VERSION = VERSION, + versionOk = versionOk, } if useLvgl then UI = loader("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua", deps) diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index ab034b7..48067c9 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -11,22 +11,6 @@ local session = deps.session local crsf = deps.crsf local VERSION = deps.VERSION -local versionCheckResult = nil - -local function checkEdgeTxVersion() - local _ver, _radio, maj, minor, rev = getVersion() - - if maj >= 3 then - return true - elseif maj == 2 and minor == 12 and rev >= 1 then - return true - elseif maj == 2 and minor == 11 and rev >= 6 then - return true - end - - return false -end - -- ============================================================================ -- UI state -- ============================================================================ @@ -80,8 +64,6 @@ function UI.init() if LCD_H == 96 then UI.maxLineIndex = 9 end - - versionCheckResult = checkEdgeTxVersion() end -- ============================================================================ @@ -89,7 +71,7 @@ end -- ============================================================================ function UI.preCheck(event) - if not versionCheckResult then + if not deps.versionOk then UI.drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 510b6aa..228e095 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -348,22 +348,6 @@ end -- EdgeTX version check -- ============================================================================ -local versionCheckResult = nil - -local function checkEdgeTxVersion() - local _ver, _radio, maj, minor, rev = getVersion() - - if maj >= 3 then - return true - elseif maj == 2 and minor == 12 and rev >= 1 then - return true - elseif maj == 2 and minor == 11 and rev >= 6 then - return true - end - - return false -end - local function showVersionRequired() lvgl.clear() @@ -413,16 +397,14 @@ end -- Interface: init -- ============================================================================ -function UI.init() - versionCheckResult = checkEdgeTxVersion() -end +function UI.init() end -- ============================================================================ -- Interface: preCheck (version gate) -- ============================================================================ function UI.preCheck(_event) - if not versionCheckResult then + if not deps.versionOk then if not UI.uiBuilt then showVersionRequired() UI.uiBuilt = true diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua index 4acbfb5..8c5f806 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -25,6 +25,7 @@ local msp = loader("/SCRIPTS/ELRS/msp.lua", crsf) local defer = loader("/SCRIPTS/ELRS/defer.lua") local FileStorage = loader("/SCRIPTS/ELRS/file_storage.lua") local History = loader("/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua", FileStorage) +local versionOk = loader("/SCRIPTS/ELRS/edgetx_version.lua")() -- ============================================================================ -- App Module: business logic shared by both UI frontends @@ -279,6 +280,7 @@ local function init() crsf = crsf, msp = msp, VERSION = VERSION, + versionOk = versionOk, } App.phrase = History.items[1] or "" if useLvgl then diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua index aad4e45..3306f8a 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -12,22 +12,6 @@ local VERSION = deps.VERSION local TextEdit = loadScript("/SCRIPTS/ELRS/ui/lcd/text_edit.lua")() -local versionCheckResult = nil - -local function checkEdgeTxVersion() - local _ver, _radio, maj, minor, rev = getVersion() - - if maj >= 3 then - return true - elseif maj == 2 and minor == 12 and rev >= 1 then - return true - elseif maj == 2 and minor == 11 and rev >= 6 then - return true - end - - return false -end - -- ============================================================================ -- UI state -- ============================================================================ @@ -89,8 +73,6 @@ function UI.init() -- The phrase window ends at the cursor; 6 px per char of the fixed BW font UI.phraseEdit = TextEdit.new(msp.CONST.PHRASE_MAX, math.floor((LCD_W - UI.COL2 - 2) / 6)) UI.phraseEdit.value = App.phrase - - versionCheckResult = checkEdgeTxVersion() end -- ============================================================================ @@ -98,7 +80,7 @@ end -- ============================================================================ function UI.preCheck(event) - if not versionCheckResult then + if not deps.versionOk then UI.drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua index 22dc742..4e4ea96 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -24,22 +24,6 @@ local UI = { -- EdgeTX version gate -- ============================================================================ -local versionCheckResult = nil - -local function checkEdgeTxVersion() - local _ver, _radio, maj, minor, rev = getVersion() - - if maj >= 3 then - return true - elseif maj == 2 and minor == 12 and rev >= 1 then - return true - elseif maj == 2 and minor == 11 and rev >= 6 then - return true - end - - return false -end - local function showVersionRequired() lvgl.clear() @@ -310,16 +294,14 @@ end -- Interface: init -- ============================================================================ -function UI.init() - versionCheckResult = checkEdgeTxVersion() -end +function UI.init() end -- ============================================================================ -- Interface: preCheck (version gate) -- ============================================================================ function UI.preCheck(_event) - if not versionCheckResult then + if not deps.versionOk then if not UI.dialogBuilt then showVersionRequired() UI.dialogBuilt = true From 9e8b8024e798cec5a7b00125f20430afe39cfb64 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:39:14 +0300 Subject: [PATCH 183/218] Share the B&W alert screen between the tools --- README.md | 1 + docs/development.md | 1 + src/SCRIPTS/ELRS/ui/lcd/alert.lua | 37 +++++++++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua | 34 ++++--------------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua | 20 ++--------- 5 files changed, 48 insertions(+), 45 deletions(-) create mode 100644 src/SCRIPTS/ELRS/ui/lcd/alert.lua diff --git a/README.md b/README.md index 94160aa..3e59f6c 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ SCRIPTS/ ui/ lcd/ text_edit.lua -- BW text editor (bind tool) + alert.lua -- BW alert screen (both tools) sensors.lua -- telemetry sensor reader file_storage.lua -- key=value file persistence shim.lua -- BW compatibility shim diff --git a/docs/development.md b/docs/development.md index e7dd929..59a3f52 100644 --- a/docs/development.md +++ b/docs/development.md @@ -66,6 +66,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | +| `SCRIPTS/ELRS/ui/lcd/alert.lua` | BW full-screen alert (MIDSIZE title, body lines, optional bottom action labels). Loaded by both tools' BW UIs | | `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | | `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | | `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | diff --git a/src/SCRIPTS/ELRS/ui/lcd/alert.lua b/src/SCRIPTS/ELRS/ui/lcd/alert.lua new file mode 100644 index 0000000..d7cd418 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/alert.lua @@ -0,0 +1,37 @@ +--------------------------------------------------------------------------- +-- B&W Alert Screen -- +-- Loaded via loadScript() with no arguments; returns the drawAlert -- +-- function. Shared by every tool's B&W UI. -- +-- -- +-- Full-screen alert: MIDSIZE title, body lines, and an optional row of -- +-- action labels along the bottom. -- +--------------------------------------------------------------------------- + +-- B&W text row height +local TEXT_H = 8 + +--- Clear the screen and draw the alert. +-- @param title heading, drawn MIDSIZE +-- @param msgs array of body lines +-- @param actions optional { left, right } action labels for the bottom row +local function drawAlert(title, msgs, actions) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (TEXT_H * 2) - 2 + for _, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + TEXT_H + end + if actions then + y = y + TEXT_H + if actions.left then + lcd.drawText(2, y, actions.left, 0) + end + if actions.right then + lcd.drawText(LCD_W - 2, y, actions.right, RIGHT) + end + end +end + +return drawAlert diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua index 48067c9..cbc0603 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -11,6 +11,8 @@ local session = deps.session local crsf = deps.crsf local VERSION = deps.VERSION +local drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() + -- ============================================================================ -- UI state -- ============================================================================ @@ -72,7 +74,7 @@ end function UI.preCheck(event) if not deps.versionOk then - UI.drawAlert("Unsupported", { + drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", "- 2.12.1 or later", @@ -119,7 +121,7 @@ end -- ============================================================================ function UI.handleNoModule() - UI.drawAlert(" No ExpressLRS", { + drawAlert(" No ExpressLRS", { "Enable a CRSF Internal", " or External module in", " Model settings", @@ -134,7 +136,7 @@ end -- ============================================================================ function UI.handleUnsupported() - UI.drawAlert("Unsupported Firmware", { + drawAlert("Unsupported Firmware", { "ELRS 1.x firmware detected.", "Please update to 3.x.", }) @@ -175,7 +177,7 @@ function UI.render(event, _touchState) App.shouldExit = true return end - UI.drawAlert("Model Mismatch", { + drawAlert("Model Mismatch", { "RX connected but", "Model ID doesn't match.", "Toggle Model Match", @@ -203,30 +205,6 @@ function UI.render(event, _touchState) end end --- ============================================================================ --- Alert screen (clear screen + title + body messages) --- ============================================================================ - -function UI.drawAlert(title, msgs, actions) - lcd.clear() - local y = 0 - lcd.drawText(2, y, title, MIDSIZE) - y = y + (UI.textSize * 2) - 2 - for _, msg in ipairs(msgs) do - lcd.drawText(2, y, msg) - y = y + UI.textSize - end - if actions then - y = y + UI.textSize - if actions.left then - lcd.drawText(2, y, actions.left, 0) - end - if actions.right then - lcd.drawText(LCD_W - 2, y, actions.right, RIGHT) - end - end -end - -- ============================================================================ -- User action handlers (call App for business logic, manage own state) -- ============================================================================ diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua index 3306f8a..56c2647 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -11,6 +11,7 @@ local msp = deps.msp local VERSION = deps.VERSION local TextEdit = loadScript("/SCRIPTS/ELRS/ui/lcd/text_edit.lua")() +local drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() -- ============================================================================ -- UI state @@ -81,7 +82,7 @@ end function UI.preCheck(event) if not deps.versionOk then - UI.drawAlert("Unsupported", { + drawAlert("Unsupported", { "Requires EdgeTX:", "- 2.11.6 or later", "- 2.12.1 or later", @@ -101,7 +102,7 @@ end -- ============================================================================ function UI.handleNoModule() - UI.drawAlert(" No ExpressLRS", { + drawAlert(" No ExpressLRS", { "Enable a CRSF Internal", " or External module in", " Model settings", @@ -111,21 +112,6 @@ function UI.handleNoModule() }) end --- ============================================================================ --- Alert screen (clear screen + title + body messages) --- ============================================================================ - -function UI.drawAlert(title, msgs) - lcd.clear() - local y = 0 - lcd.drawText(2, y, title, MIDSIZE) - y = y + (UI.textSize * 2) - 2 - for _, msg in ipairs(msgs) do - lcd.drawText(2, y, msg) - y = y + UI.textSize - end -end - -- ============================================================================ -- Row list -- ============================================================================ From d6ee850e43cf516e3132d8fd668fafccee4287e7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 08:52:37 +0300 Subject: [PATCH 184/218] Share the LVGL startup dialogs between the tools --- README.md | 2 + docs/development.md | 1 + src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua | 81 ++++++++++++++ src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua | 108 ++----------------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua | 98 +---------------- 5 files changed, 95 insertions(+), 195 deletions(-) create mode 100644 src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua diff --git a/README.md b/README.md index 3e59f6c..68a847c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ SCRIPTS/ lcd/ text_edit.lua -- BW text editor (bind tool) alert.lua -- BW alert screen (both tools) + lvgl/ + dialogs.lua -- color startup dialogs (both tools) sensors.lua -- telemetry sensor reader file_storage.lua -- key=value file persistence shim.lua -- BW compatibility shim diff --git a/docs/development.md b/docs/development.md index 59a3f52..ba97623 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,6 +67,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | | `SCRIPTS/ELRS/ui/lcd/alert.lua` | BW full-screen alert (MIDSIZE title, body lines, optional bottom action labels). Loaded by both tools' BW UIs | +| `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | The color-LCD startup dialogs a tool can raise before it has a page -- the version gate and the missing-module notice. Both are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs | | `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | | `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | | `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | diff --git a/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua b/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua new file mode 100644 index 0000000..b05d031 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua @@ -0,0 +1,81 @@ +--------------------------------------------------------------------------- +-- Color LCD Startup Dialogs -- +-- Loaded via loadScript() with no arguments; returns the Dialogs table. -- +-- Shared by every tool's LVGL UI. -- +-- -- +-- The two dialogs a tool can raise before it has a page: the EdgeTX -- +-- version gate and the missing-module notice. Both are terminal -- the -- +-- only way out is exiting the tool -- so each takes the caller's -- +-- onExit and wires it to both the dialog's close box and its Exit -- +-- button. -- +--------------------------------------------------------------------------- + +local Dialogs = {} + +--- Build a full-screen dialog: a column of text lines over a single Exit +-- button. lines are label descriptors ({ text = ..., font = ... }), used +-- verbatim as children. +local function buildExitDialog(title, lines, onExit) + lvgl.clear() + + local dg = lvgl.dialog({ + title = title, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = onExit, + }) + + dg:build({ + { + type = lvgl.BOX, + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = lines, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 98, + text = "Exit", + press = function() + dg:close() + onExit() + end, + }, + }, + }, + }) + + return dg +end + +--- The EdgeTX version gate. Keep the versions in step with the ladder in +-- SCRIPTS/ELRS/edgetx_version.lua, which decides when this is shown. +function Dialogs.showVersionRequired(onExit) + return buildExitDialog("EdgeTX Version Not Supported", { + { type = lvgl.LABEL, text = "Requires EdgeTX:" }, + { type = lvgl.LABEL, text = "- 2.11.6 or later" }, + { type = lvgl.LABEL, text = "- 2.12.1 or later" }, + { type = lvgl.LABEL, text = "- 3.0 or later" }, + }, onExit) +end + +--- No CRSF module configured on the model. +function Dialogs.showNoModule(onExit) + return buildExitDialog("No Module Found: Check Model Settings", { + { type = lvgl.LABEL, text = "- Internal/External module enabled" }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, + { type = lvgl.LABEL, text = "- Minimum Baud rate (depends on packet rate):" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 400k for 250Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 921k for 500Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 1.87M for F1000" }, + }, onExit) +end + +return Dialogs diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua index 228e095..ccbb7d0 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -11,6 +11,8 @@ local session = deps.session local crsf = deps.crsf local VERSION = deps.VERSION +local SharedDialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + -- ============================================================================ -- UI state -- ============================================================================ @@ -109,56 +111,11 @@ function ModelMismatchDialog.show(onContinue, onExit) end -- ============================================================================ --- NoModuleDialog +-- Startup dialogs (version gate, no module): SCRIPTS/ELRS/ui/lvgl/dialogs.lua -- ============================================================================ -local NoModuleDialog = {} - -function NoModuleDialog.show(onExit) - lvgl.clear() - - local dg = lvgl.dialog({ - title = "No Module Found: Check Model Settings", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = onExit, - }) - - dg:build({ - { - type = lvgl.BOX, - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = lvgl.LABEL, text = "- Internal/External module enabled" }, - { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, - { type = lvgl.LABEL, text = "- Minimum Baud rate (depends on packet rate):" }, - { type = lvgl.LABEL, font = SMLSIZE, text = " 400k for 250Hz" }, - { type = lvgl.LABEL, font = SMLSIZE, text = " 921k for 500Hz" }, - { type = lvgl.LABEL, font = SMLSIZE, text = " 1.87M for F1000" }, - }, - }, - { - type = lvgl.BOX, - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - flexFlow = lvgl.FLOW_ROW, - children = { - { - type = lvgl.BUTTON, - w = lvgl.PERCENT_SIZE + 98, - text = "Exit", - press = function() - dg:close() - onExit() - end, - }, - }, - }, - }) - - return dg +local function exitTool() + App.shouldExit = true end -- ============================================================================ @@ -344,55 +301,6 @@ function CommandPage.showExecuting(title, getInfo, onCancel) return pg end --- ============================================================================ --- EdgeTX version check --- ============================================================================ - -local function showVersionRequired() - lvgl.clear() - - local dg = lvgl.dialog({ - title = "EdgeTX Version Not Supported", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = function() - App.shouldExit = true - end, - }) - - dg:build({ - { - type = "box", - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = "label", text = "Requires EdgeTX:" }, - { type = "label", text = "- 2.11.6 or later" }, - { type = "label", text = "- 2.12.1 or later" }, - { type = "label", text = "- 3.0 or later" }, - }, - }, - { - 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, - }, - }, - }, - }) -end - -- ============================================================================ -- Interface: init -- ============================================================================ @@ -406,7 +314,7 @@ function UI.init() end function UI.preCheck(_event) if not deps.versionOk then if not UI.uiBuilt then - showVersionRequired() + SharedDialogs.showVersionRequired(exitTool) UI.uiBuilt = true end if App.shouldExit then @@ -450,9 +358,7 @@ end function UI.handleNoModule() if not UI.uiBuilt then - NoModuleDialog.show(function() - App.shouldExit = true - end) + SharedDialogs.showNoModule(exitTool) UI.uiBuilt = true end end diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua index 4e4ea96..3b45b45 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -9,6 +9,8 @@ local App = deps.App local crsf = deps.crsf local msp = deps.msp +local SharedDialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + -- ============================================================================ -- UI state -- ============================================================================ @@ -20,98 +22,6 @@ local UI = { dialogBuilt = false, } --- ============================================================================ --- EdgeTX version gate --- ============================================================================ - -local function showVersionRequired() - lvgl.clear() - - local dg = lvgl.dialog({ - title = "EdgeTX Version Not Supported", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = function() - App.shouldExit = true - end, - }) - - dg:build({ - { - type = "box", - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = "label", text = "Requires EdgeTX:" }, - { type = "label", text = "- 2.11.6 or later" }, - { type = "label", text = "- 2.12.1 or later" }, - { type = "label", text = "- 3.0 or later" }, - }, - }, - { - 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, - }, - }, - }, - }) -end - -local function showNoModule() - lvgl.clear() - - local dg = lvgl.dialog({ - title = "No Module Found: Check Model Settings", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - close = function() - App.shouldExit = true - end, - }) - - dg:build({ - { - type = lvgl.BOX, - x = 10, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - children = { - { type = lvgl.LABEL, text = "- Internal/External module enabled" }, - { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, - }, - }, - { - type = lvgl.BOX, - w = lvgl.PERCENT_SIZE + 100, - align = CENTER, - flexFlow = lvgl.FLOW_ROW, - children = { - { - type = lvgl.BUTTON, - w = lvgl.PERCENT_SIZE + 98, - text = "Exit", - press = function() - dg:close() - App.shouldExit = true - end, - }, - }, - }, - }) -end - -- ============================================================================ -- Main page -- ============================================================================ @@ -303,7 +213,7 @@ function UI.init() end function UI.preCheck(_event) if not deps.versionOk then if not UI.dialogBuilt then - showVersionRequired() + SharedDialogs.showVersionRequired(exitTool) UI.dialogBuilt = true end if App.shouldExit then @@ -321,7 +231,7 @@ end function UI.handleNoModule() if not UI.dialogBuilt then - showNoModule() + SharedDialogs.showNoModule(exitTool) UI.dialogBuilt = true end end From 1e44cae352f00b109f6e36bbd31da8d353e1975c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 09:12:07 +0300 Subject: [PATCH 185/218] Match the firmware's receiver antenna parameter naming The CRSF mock mirrors the receiver's parameter list, which now reads "Antenna Mode" with "Antenna 1;Antenna 2;Diversity". --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index ffd49e3..f7f7b9c 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -733,8 +733,8 @@ local rxDevice = { id = 3, parent = 0, type = CRSF.TEXT_SELECTION, - name = "Ant. Mode", - options = "Antenna A;Antenna B;Diversity", + name = "Antenna Mode", + options = "Antenna 1;Antenna 2;Diversity", value = 2, units = "", }, From e7b168061677b358e1a300adc48dbfcc3abf523b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 11:03:56 +0300 Subject: [PATCH 186/218] Rework the bind tool around the transmitter's identity One string carried both transient status and the UID bytes, and it lived in the page subtitle -- so the header changed meaning as you used it, and the only value the tool read back had no home of its own. Split it into a phrase form, an identity block, and feedback attached to the control that caused it. Drop "Request UID". Every event it existed to cover -- the target selector moving, a first read falling silent -- is one the App can observe, so the probe runs on tool open, after each write and on a target change, keeping the bounded six-attempt retry that reports a pre-4.1 module. Stop reading the receiver at all. It answers only over a link, and a link only exists between devices already sharing a UID, so its answer could never differ from the transmitter's -- while the link's presence says the same thing for free and costs no over-air traffic. The row now reports Connected / Not connected, and the simulator no longer seeds the two devices with different UIDs, a state the radios cannot be in. Bind and Unbind confirm before acting, which is where the advice belongs: the firmware reaches EnterBindingModeSafely at either address, so "unbind" puts the receiver into bind mode rather than erasing its binding, and the other half of the pairing is still the user's to do. The Set button carries the write sequence's progress instead of a floating status line, which frees the row that restores [EXIT] on 128x64. Set also moves to its own row with fixed text, the phrase field stays editable regardless of what is reachable, and a Both write no longer flips the selector to Transmitter behind the user. --- README.md | 17 +- docs/development.md | 28 ++- screenshots/tool_bind.png | Bin 25261 -> 33390 bytes screenshots/tool_bind_bw.png | Bin 4138 -> 6404 bytes src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 9 +- src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua | 238 +++++++++++++------ src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua | 195 ++++++++------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua | 212 +++++++++-------- 8 files changed, 419 insertions(+), 280 deletions(-) diff --git a/README.md b/README.md index 68a847c..02d0ada 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,19 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t ## ExpressLRS Bind Phrase Manager The bind tool (`SCRIPTS/TOOLS/ExpressLRSBind/`) sets the bind phrase -- or a raw UID entered as -comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, reads the -current UID back for verification, and can put the TX in bind mode or unbind a connected receiver. -The last five phrases are kept as a pick-and-send history. Setting the phrase over MSP requires -**ExpressLRS 4.1+** on the device. +comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, and can put the +TX in bind mode or unbind a connected receiver. The last five phrases are kept as a pick-and-send +history. + +The transmitter's UID is read back automatically -- when the tool opens, after every write, and +whenever you change the target -- so the page always shows the identity the handset is transmitting +on. The receiver's own UID is not read: a receiver only answers over an active link, and a link only +exists between devices that already share a UID, so its answer could never differ from the +transmitter's. What is worth knowing is whether it is there at all, which the link reports for free. + +Setting the phrase over MSP requires **ExpressLRS 4.1+** on the device; a pre-4.1 module never +answers, and the tool says so in the transmitter row rather than retrying forever. Bind and unbind +are plain CRSF commands and work on every ExpressLRS version. ExpressLRS Bind Phrase Manager
diff --git a/docs/development.md b/docs/development.md index ba97623..8213cdc 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,14 +43,21 @@ The configuration tool, `SCRIPTS/TOOLS/ExpressLRS/`: The bind phrase manager, `SCRIPTS/TOOLS/ExpressLRSBind/` (sets the bind phrase / UID over MSP; the device side requires ExpressLRS 4.1+, and a pre-4.1 device simply never answers -- the tool's -bounded UID retry reports that instead of polling forever): +bounded UID retry reports that instead of polling forever). + +Only the transmitter's UID is read, and only on the events that can change it: the tool opening, a +write completing, and the target selector moving. The receiver is never asked. It answers over the +link, a link only exists between devices already sharing a UID, so its answer is the transmitter's +number by construction; the link's presence carries the whole of what asking would have told us, and +costs no over-air traffic. That is also why there is no manual refresh -- every event a "read UID" +button existed to cover is one the App observes for itself: | Module | Purpose | |--------|---------| -| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded UID read retry, and the frame router over `crsf.drain` | +| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded transmitter UID probe, and the frame router over `crsf.drain` | | `history_storage.lua` | The last five phrases, newest first, persisted through `file_storage.lua` as indexed keys `h1`..`h5` | | `ui/lvgl.lua` | Color LCD interface | -| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`) | +| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`). Note `popupConfirmation`'s message argument never reaches the screen on BW: EdgeTX's Lua binding sets `warningInfoText` without `warningInfoLength` (`api_general.cpp` `luaPopupConfirmation`), so a confirmation has only its 24-char title (`WARNING_LINE_LEN`) to say what it needs | Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds. @@ -123,13 +130,14 @@ The simulator supports multiple test scenarios, configurable via the `config.sce ### MSP bind traffic The mock answers the bind tool's MSP `RXTX_CONFIG` traffic: a UID read (`MSP_REQ`) is served from a -per-device `mspUid` table -- the TX and RX deliberately start with different UIDs so a fresh `normal` -run shows a mismatch that setting both to one phrase visibly fixes -- and a phrase write -(`MSP_WRITE`) rederives the target's UID through a deterministic pseudo-hash, so equal phrases give -equal UIDs (the property the Both flow demonstrates; the bytes need not match the firmware's MD5). -The RX answers only while the scenario keeps it reachable, which is what exercises the tool's -bounded retry, and writes are unacknowledged just like the real firmware. `FRAMETYPE_COMMAND` -bind/unbind requests are log-only. +per-device `mspUid` table, and a phrase write (`MSP_WRITE`) rederives the target's UID through a +deterministic pseudo-hash, so equal phrases give equal UIDs (the property the Both flow +demonstrates; the bytes need not match the firmware's MD5). Both devices start on the same UID, +because a reachable receiver whose UID differs from the transmitter's is a state the radios cannot +be in. The RX answers only while the scenario keeps it reachable, and writes are unacknowledged just +like the real firmware. `FRAMETYPE_COMMAND` bind requests are log-only -- note that the firmware +handles that command identically at either address (`EnterBindingModeSafely`), so the "unbind" +button puts the receiver into bind mode rather than erasing its binding. `config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as diff --git a/screenshots/tool_bind.png b/screenshots/tool_bind.png index 5083b31e7eb9c018ce2bd406c779b013e5935859..b3c35e939f3053950256e01888629d03beb72ebe 100644 GIT binary patch literal 33390 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 KaO`_4xBtJzf~!gZ literal 25261 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 b`ia=vx8KlrOX;s0k(GDc)6~2CdmH}?Hj#15 literal 4138 zcmaJ_Yiv|S6y8#>*b1dVT_4m@>5{(e3VH04f=`BqN z5XB!7Xc|M8N0eGZ`-4bRDc-h%yj>L5{%C1|P?j|Gxsq6OMe{J4GLT7Of|mlx*W zZeUIIHs{#vUZ1<-`{s&GmHl&n^)`D>Yzl0}W7-zJIynA;b0pF|w616HnrA(>r=Gmj zb#meOI17&|0SxAYc8PK^g&FCQPYO=X3`1lGv$!<9EDQ{WUu zHNnl{(S9rO9U^Y51_-tFc~|%~ZWF(53-NP<<1hq|fJFob0|9vVfO;q^_S;AZce{@AS_uqG za%Z)he@a*Arj~wVJ1~uf9bCjW&ur5m*os&s@-VJv*r)cUU02XjN^VJKtfTypqe@;B zVmIYW0yuB>FE{qCy;#Cr2b`f}uKA@AqoA)LFal&NfGu0LqL*_o4f(Vw*BaOrL2Jql z%rr22eK><}%7)#v>(ZiH3=lpMEhZv4ITZuSobwn?6Y~T;5bow!89A!JnZ!l7LPkJP zNfh6JbcbwFh`aG)9Mx&lyrQb5@K}L}HVBI3If<6qZr^~UieAhi?_F-xvtFw=tLEUS zLuD#1Zjc)`Y;dbI(#$u@yNQOuMcjTSXoI3us7>s4PL{a+ZlGkn2@l!xmuEDWt**NO zJX|*<4AxxKVqI4}P=o~?{h^*rNhXIYP-;|l;qozFS2k6uta#%!a-G`nQihm zl02$uQY#`&ip;W?y$nxeu2n^_)Ag;hC=-&I8s{{+PE&_SC~+!2YSsnBktn<|=j^eg zW;~>1B%DMG1Q13@tVTsI;|k}#-8W6eKt|bj8aJr`jC1Np5o8ZeXht}`;oO40X-e{q zT{^%r3PA!*ss)8=mKdX-n`;^;So$^IlWOAu)v(sojL@UPCuJkK@$8qGpo7oc0XrAV z6i|hesF64>@+6{~F!EQ;hbmIY^NsxC~Mjd56#R^B6RBC8}#s z3i!Z6NB(e`%CgiXVv7B=Rugbo>kQivm}roYpH38qwnAhQ3%Hwz2KoF1Wkix^RE#nNAs`LXBNk29Z@lm# zOIL0sb#?1!et|%*fo+(|mh*e#<{9LVW?asEA^82q)M|s~OEC{{=X3)!O;Rr{8*@%+ zH-FQ(N>kDi>&&wc7NCbwHfTgd!Vp>j<8n*CnT@^}%mHF_DK)L+*@Fa5%vRd|vN2bB z9r2i;570U-c+p=C9m-_QX{w5b)5V#&*Q&JU9g!fXyUy7{X$L zj`JAhsk>=48S9_?ltwO<1)cnA)$KPQZl9+<1V~%rWSV#qgNN5Y*`mBq%~M+=CO1xG zE**YPh0uYJ;#!{BiQvTWLa3Ner>Ie$6(einTgC@S52{ZIatVAaVe3!fHH7ISD4M7! zB^9V=9~!NuXj%ZG|F}kZ-f#ys-V*lNog(XQ>80pwTp3sbsE;)FJt~XE}(m(wB;kEU@WFrVgJbkovGDz7EYt_OKgNb_iCR3ZsCow6euJ z9$H>AYM}*Qu*VfM= UID_MAX_ATTEMPTS then - App.statusText = "No response (needs ELRS 4.1+)" + if App.probeAttempts >= UID_MAX_ATTEMPTS then + App.uidState = App.UID_SILENT + App.probing = false return end - App.uidAttempts = App.uidAttempts + 1 - App.statusText = "Updating..." - local dest = (App.target == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX - crsf.push(msp.encodeUidRead(dest, crsf.CONST.ADDRESS_HANDSET)) - defer.setTimeout(UID_RETRY_TICKS, App.requestUid) + App.probeAttempts = App.probeAttempts + 1 + crsf.push(msp.encodeUidRead(crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET)) + defer.setTimeout(UID_RETRY_TICKS, App.probeStep) end ---- User-facing entry point: start a fresh UID request cycle. -function App.startUidRequest() - App.uidAttempts = 0 - App.requestUid() +function App.startProbe() + if App.setInFlight then + return + end + App.probing = true + App.probeAttempts = 0 + App.probeStep() end ---- Send the phrase (or raw UID) to the selected target. "Both" is a --- two-step sequence: the RX first -- writing its phrase drops it off the --- link -- then the TX, after which the selector rests on Transmitter. -function App.sendSet() - if App.phrase == "" then +--- Drop an in-flight probe so a user action can take the defer slot. +local function cancelProbe() + App.probing = false +end + +-- ============================================================================ +-- Actions +-- ============================================================================ + +--- Point the tool at a different side. Also the natural retry: a user who +-- moves the selector after a silent read gets a fresh look at the module. +function App.setTarget(n) + if n == App.target then return end - if App.target == App.TARGET_BOTH then - if App.bothStep == nil then - App.bothStep = App.TARGET_RX - else - App.bothStep = nil - -- The selector visibly rests on Transmitter after the sequence; the - -- CHOICE renders its build-time selection, so this needs a rebuild. - App.target = App.TARGET_TX - App.rev = App.rev + 1 - end - end - local effective = App.bothStep or App.target - if App.bothStep == nil then - App.statusText = (App.target == App.TARGET_TX) and "Setting transmitter..." or "Setting receiver..." - else - App.statusText = "Setting RX and disconnecting..." - end - local dest = (effective == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + App.target = n + App.startProbe() +end + +--- The write sequence's last step: release the defer slot and read back +-- what the transmitter actually stored. +local function finishSet() + App.setInFlight = false + App.bothStep = nil + App.writeSide = nil + App.startProbe() +end + +--- Write the phrase (or raw UID) to the side the sequence is on, then +-- either advance to the transmitter or finish. +function App.writeStep() + local side = App.bothStep + local isTx = side == App.TARGET_TX + App.writeSide = side + local dest = isTx and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX local uid = App.parseUidText(App.phrase) if uid then crsf.push(msp.encodeUidWrite(dest, crsf.CONST.ADDRESS_HANDSET, uid)) else crsf.push(msp.encodePhraseWrite(dest, crsf.CONST.ADDRESS_HANDSET, App.phrase)) end - History.add(App.phrase) - if App.bothStep == nil then - defer.setTimeout(FOLLOWUP_TICKS, App.startUidRequest) + if App.target == App.TARGET_BOTH and not isTx then + App.bothStep = App.TARGET_TX + defer.setTimeout(FOLLOWUP_TICKS, App.writeStep) else - defer.setTimeout(FOLLOWUP_TICKS, App.sendSet) + defer.setTimeout(FOLLOWUP_TICKS, finishSet) end end -local function markSent() - App.statusText = "Sent" +--- Send the phrase to the selected side. "Both" runs as a two-step +-- sequence: the receiver first -- writing its phrase drops it off the link +-- -- then the transmitter, which brings the link back on the new identity. +function App.sendSet() + if not App.isSetEnabled() then + return + end + cancelProbe() + App.setInFlight = true + App.bothStep = (App.target == App.TARGET_BOTH) and App.TARGET_RX or App.target + History.add(App.phrase) + App.writeStep() end ---- Put the TX module in bind mode, catching an RX waiting in bind mode. +--- Put the TX module in bind mode, meeting an RX that is in bind mode too +-- (firmware: both addresses of this command reach EnterBindingModeSafely). +-- A plain CRSF command, so unlike the phrase write it works on every ELRS +-- version -- which is what keeps the tool useful on a pre-4.1 setup. +-- Neither side reports its bind state back, so the UIs say what the user +-- still has to do rather than claiming a result. function App.sendBind() - App.statusText = "Sending bind command..." crsf:sendBindCommand(crsf.CONST.ADDRESS_TX) - defer.setTimeout(FOLLOWUP_TICKS, markSent) end ---- Unbind the connected receiver. +--- Send the same command to the connected receiver, which drops the link +-- and leaves it listening for a bind. function App.sendUnbind() - App.statusText = "Sending unbind to RX..." crsf:sendBindCommand(crsf.CONST.ADDRESS_RX) - defer.setTimeout(FOLLOWUP_TICKS, markSent) end function App.useHistory(i) @@ -250,19 +331,22 @@ function App.clearHistory() end --- Frame router for crsf.drain(): the tool is the sole drainer in its Lua --- state, and the only traffic it consumes is the MSP UID answer. +-- state, and the only traffic it consumes is the transmitter's MSP UID +-- answer. Source gating lives here, as msp.lua leaves it to the caller. function App.onFrame(_consumer, command, data) - if command ~= crsf.CONST.FRAMETYPE_MSP_RESP then + if command ~= crsf.CONST.FRAMETYPE_MSP_RESP or data[2] ~= crsf.CONST.ADDRESS_TX then return end - local srcId = msp.decodeUid(data, App.uid) - if srcId == nil then + if msp.decodeUid(data, App.uid) == nil then return end - defer.clear() - App.uidFrom = srcId - App.statusText = nil - App.uidAttempts = 0 + App.uidState = App.UID_OK + -- Only an answer we are waiting for ends the retry; a late duplicate + -- must not cancel a write sequence's follow-up. + if App.probing then + defer.clear() + App.probing = false + end end -- ============================================================================ @@ -290,8 +374,8 @@ local function init() end UI.init() -- One tick of delay so run()'s first drain creates the telemetry queue - -- before the request's answer can land. - defer.setTimeout(1, App.startUidRequest) + -- before the answer can land. + defer.setTimeout(1, App.startProbe) -- The returned table stays on the standalone Lua stack and pins init(), -- which holds VERSION and useLvgl as upvalues. Drop it. M.init = nil diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua index 56c2647..5440a80 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -17,12 +17,12 @@ local drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() -- UI state -- ============================================================================ --- Row ids (numeric to save RAM). History items are encoded as HIST_BASE + i. +-- Cursor stop ids (numeric to save RAM). History items are HIST_BASE + i. local ROW_PHRASE = 1 local ROW_TARGET = 2 -local ROW_UID = 3 -local ROW_SET = 4 -local ROW_BIND = 5 -- renders Bind or Unbind depending on the link +local ROW_SET = 3 +local ROW_BIND = 4 +local ROW_UNBIND = 5 local ROW_HISTORY = 6 local ROW_EXIT = 7 local ROW_CLEAR = 8 @@ -44,23 +44,31 @@ local UI = { ---@type table constructed in init(), needs LCD_W for the window phraseEdit = nil, - -- Pending confirmation popup: { msg, i (history index) or nil = clear all } + -- Pending confirmation popup: { msg, info, fn }. Only msg reaches the + -- screen: EdgeTX's Lua binding sets warningInfoText without + -- warningInfoLength, so the BW popup draws the info line zero chars wide + -- (api_general.cpp luaPopupConfirmation). Keep msg under 24 chars -- + -- WARNING_LINE_LEN -- and say what matters there. confirm = nil, - -- Layout constants for 128x64; UI.init widens COL2 at 212px wide + -- Layout constants for 128x64; UI.init widens COL2 at 212 px wide COL1 = 0, - COL2 = 70, + COL2 = 54, textSize = 8, textYoffset = 3, + ---@type table row baselines, filled by init() for the screen height + y = nil, - -- Redraw state: the page repaints on events and whenever the live status - -- line changes (statusLine below), tracked as a string compare per frame. + -- Redraw state: the page repaints on events and whenever a live part of + -- it changes (statusKey below), tracked as a string compare per frame. forceRedraw = true, lastStatus = nil, } --- Both pages fit the 8-row 128x64 grid (main 7 rows, history at most 7), --- so there is no scrolling and no page offset in this UI. +-- 128x64 has room for three normal rows, the two report lines and one +-- action row, which is why the three actions share a line at SMLSIZE. +local Y_SHORT = { phrase = 9, target = 17, set = 25, tx = 34, rx = 41, actions = 48, exit = 56 } +local Y_TALL = { phrase = 10, target = 20, set = 30, tx = 44, rx = 52, actions = 62, exit = 76 } -- ============================================================================ -- Interface: init @@ -70,6 +78,7 @@ function UI.init() if LCD_W == 212 then UI.COL2 = 110 end + UI.y = (LCD_H >= 96) and Y_TALL or Y_SHORT -- The phrase window ends at the cursor; 6 px per char of the fixed BW font UI.phraseEdit = TextEdit.new(msp.CONST.PHRASE_MAX, math.floor((LCD_W - UI.COL2 - 2) / 6)) @@ -113,7 +122,7 @@ function UI.handleNoModule() end -- ============================================================================ --- Row list +-- Cursor stops -- ============================================================================ local function buildRows() @@ -124,10 +133,12 @@ local function buildRows() if UI.page == PAGE_MAIN then rows[1] = ROW_PHRASE rows[2] = ROW_TARGET - rows[3] = ROW_UID - rows[4] = ROW_SET - if App.target == App.TARGET_RX then - rows[5] = ROW_BIND + rows[3] = ROW_SET + rows[4] = ROW_BIND + -- Unbind appears only with a receiver to unbind, so the cursor never + -- lands on a dead action -- and Bind keeps its place either way. + if crsf.hasTelemetry then + rows[#rows + 1] = ROW_UNBIND end rows[#rows + 1] = ROW_HISTORY rows[#rows + 1] = ROW_EXIT @@ -143,16 +154,6 @@ local function buildRows() end end ---- The UID/status row text: compact so it fits 128 px at SMLSIZE. -local function statusLine() - if App.statusText then - return App.statusText - end - local u = App.uid - local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" - return string.format("%s: %d,%d,%d,%d,%d,%d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) -end - -- ============================================================================ -- Navigation and actions -- ============================================================================ @@ -176,30 +177,25 @@ end local function handleEnter(id) if id == ROW_PHRASE then - if App.isTargetReachableOrBoth() then - UI.phraseEdit.value = App.phrase - UI.phraseEdit:start() - end + UI.phraseEdit.value = App.phrase + UI.phraseEdit:start() elseif id == ROW_TARGET then UI.editTarget = true - elseif id == ROW_UID then - App.startUidRequest() elseif id == ROW_SET then - if App.isTargetReachableOrBoth() and App.phrase ~= "" then - App.sendSet() - end + App.sendSet() elseif id == ROW_BIND then - if crsf.hasTelemetry then - App.sendUnbind() - else - App.sendBind() - end + -- Bind is a mode the module enters, not an event that completes, and + -- the other half of the pairing is the user's to do -- so the advice + -- belongs before the press, not after it. + UI.confirm = { msg = "Bind transmitter?", info = "Also bind the RX", fn = App.sendBind } + elseif id == ROW_UNBIND then + UI.confirm = { msg = "Unbind receiver?", info = "Link will drop", fn = App.sendUnbind } elseif id == ROW_HISTORY then goToPage(PAGE_HISTORY) elseif id == ROW_EXIT then App.shouldExit = true elseif id == ROW_CLEAR then - UI.confirm = { msg = "Clear history?" } + UI.confirm = { msg = "Clear history?", info = "[OK] to confirm", fn = App.clearHistory } elseif id == ROW_BACK then goToPage(PAGE_MAIN) elseif id > HIST_BASE then @@ -221,9 +217,9 @@ local function handleEvent(event) -- Target edit: rotary cycles, ENTER or EXIT commits if UI.editTarget then if event == EVT_VIRTUAL_NEXT then - App.target = math.min(App.target + 1, App.TARGET_BOTH) + App.setTarget(math.min(App.target + 1, App.TARGET_BOTH)) elseif event == EVT_VIRTUAL_PREV then - App.target = math.max(App.target - 1, App.TARGET_TX) + App.setTarget(math.max(App.target - 1, App.TARGET_TX)) elseif event == EVT_VIRTUAL_ENTER or event == EVT_VIRTUAL_EXIT then UI.editTarget = nil end @@ -242,7 +238,14 @@ local function handleEvent(event) killEvents(event) local id = UI.rows[UI.lineIndex] if id and id > HIST_BASE then - UI.confirm = { msg = "Delete entry?", i = id - HIST_BASE } + local i = id - HIST_BASE + UI.confirm = { + msg = "Delete entry?", + info = "[OK] to confirm", + fn = function() + App.removeHistory(i) + end, + } end elseif event == EVT_VIRTUAL_NEXT then selectRow(1) @@ -262,33 +265,56 @@ local function drawTitle() lcd.drawText(LCD_W - 1, 1, crsf.hasTelemetry and "C" or "-", INVERS + RIGHT) end -local function drawRow(id, yPos, isSelected) - local attr = isSelected and INVERS or 0 - if id == ROW_PHRASE then - lcd.drawText(UI.COL1, yPos, "Phrase", 0) - UI.phraseEdit:draw(UI.COL2, yPos, attr) - elseif id == ROW_TARGET then - if UI.editTarget and isSelected then - attr = attr + BLINK +local function attrFor(id) + return (UI.rows[UI.lineIndex] == id) and INVERS or 0 +end + +local function drawMain() + local y = UI.y + + lcd.drawText(UI.COL1, y.phrase, "Phrase", 0) + UI.phraseEdit:draw(UI.COL2, y.phrase, attrFor(ROW_PHRASE)) + + local targetAttr = attrFor(ROW_TARGET) + if UI.editTarget then + targetAttr = targetAttr + BLINK + end + lcd.drawText(UI.COL1, y.target, "Apply to", 0) + lcd.drawText(UI.COL2, y.target, TARGET_NAMES[App.target], targetAttr) + + -- The one action that changes a device: its own row, and its label + -- doubles as the write sequence's progress + local setAttr = attrFor(ROW_SET) + if App.isSetEnabled() then + setAttr = setAttr + BOLD + end + lcd.drawText(LCD_W / 2, y.set, "[" .. App.setLabel(true) .. "]", setAttr + CENTER) + + -- What the devices report back + lcd.drawText(UI.COL1, y.tx, "TX " .. App.uidText(true), SMLSIZE) + lcd.drawText(UI.COL1, y.rx, "RX " .. App.receiverText(), SMLSIZE) + + lcd.drawText(UI.COL1 + 1, y.actions, "[Bind]", attrFor(ROW_BIND) + SMLSIZE) + if crsf.hasTelemetry then + lcd.drawText(math.floor(LCD_W * 0.3), y.actions, "[Unbind]", attrFor(ROW_UNBIND) + SMLSIZE) + end + lcd.drawText(LCD_W - 1, y.actions, "[History]", attrFor(ROW_HISTORY) + SMLSIZE + RIGHT) + + lcd.drawText(LCD_W / 2, y.exit, "[---- EXIT ----]", attrFor(ROW_EXIT) + BOLD + CENTER) +end + +local function drawHistory() + for i = 1, #UI.rows do + local id = UI.rows[i] + local yPos = i * UI.textSize + UI.textYoffset + local attr = (UI.lineIndex == i) and INVERS or 0 + if id == ROW_CLEAR then + lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) + elseif id == ROW_BACK then + lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) + else + lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) end - lcd.drawText(UI.COL1, yPos, "Target", 0) - lcd.drawText(UI.COL2, yPos, TARGET_NAMES[App.target], attr) - elseif id == ROW_UID then - lcd.drawText(UI.COL1, yPos, statusLine(), attr + SMLSIZE) - elseif id == ROW_SET then - lcd.drawText(10, yPos, "[Set]", attr + BOLD) - elseif id == ROW_BIND then - lcd.drawText(10, yPos, crsf.hasTelemetry and "[Unbind]" or "[Bind]", attr + BOLD) - elseif id == ROW_HISTORY then - lcd.drawText(UI.COL1, yPos, "> History", attr + BOLD) - elseif id == ROW_EXIT then - lcd.drawText(10, yPos, "[---- EXIT ----]", attr + BOLD) - elseif id == ROW_CLEAR then - lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) - elseif id == ROW_BACK then - lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) - elseif id > HIST_BASE then - lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) end end @@ -299,8 +325,10 @@ local function drawPage(event) lcd.clear() drawTitle() - for i = 1, #UI.rows do - drawRow(UI.rows[i], i * UI.textSize + UI.textYoffset, UI.lineIndex == i) + if UI.page == PAGE_MAIN then + drawMain() + else + drawHistory() end end @@ -308,12 +336,19 @@ end -- Interface: render -- ============================================================================ +--- Everything on the page that can change without an event: repainting on +-- a change keeps the UID rows and status live without repainting at frame +-- rate. +local function statusKey() + return App.setLabel(true) .. App.uidText(true) .. (crsf.hasTelemetry and "C" or "-") +end + function UI.render(event, _touchState) -- Pending confirmation owns the screen until answered. It arms only after - -- a quiet frame: the ENTER release that follows the long press which - -- opened it would otherwise answer it on the spot. + -- a quiet frame: the ENTER release that follows the press which opened it + -- would otherwise answer it on the spot. if UI.confirm then - local result = popupConfirmation(UI.confirm.msg, "PRESS [OK] to confirm", event) + local result = popupConfirmation(UI.confirm.msg, UI.confirm.info, event) if not UI.confirm.armed then if event == 0 then UI.confirm.armed = true @@ -321,11 +356,7 @@ function UI.render(event, _touchState) return end if result == "OK" then - if UI.confirm.i then - App.removeHistory(UI.confirm.i) - else - App.clearHistory() - end + UI.confirm.fn() UI.confirm = nil UI.forceRedraw = true elseif result == "CANCEL" then @@ -335,9 +366,7 @@ function UI.render(event, _touchState) return end - -- Repaint on any event, while editing, and whenever the live parts of the - -- page (status line, link flag) changed since the last paint. - local status = statusLine() .. (crsf.hasTelemetry and "C" or "-") + local status = statusKey() if event ~= 0 or UI.forceRedraw or UI.phraseEdit.editing or status ~= UI.lastStatus then drawPage(event) UI.lastStatus = status diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua index 3b45b45..a134279 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -22,6 +22,14 @@ local UI = { dialogBuilt = false, } +-- Same split the config tool uses: label column, value column. +local IS_NARROW = LCD_W < 400 +local LABEL_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 42 or 50) +local VALUE_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 58 or 50) +local FULL_PCT = lvgl.PERCENT_SIZE + 100 + +local TARGET_VALUES = { "Transmitter", "Receiver", "Both" } + -- ============================================================================ -- Main page -- ============================================================================ @@ -30,156 +38,157 @@ local function exitTool() App.shouldExit = true end -local function isSetEnabled() - return App.isTargetReachableOrBoth() and App.phrase ~= "" +local function hasLink() + return crsf.hasTelemetry +end + +-- Own wrappers: lvgl calls a text getter with no arguments, and both +-- getters take a compact-format flag as their first parameter. +local function uidText() + return App.uidText() end -local function isRxSelected() - return App.target == App.TARGET_RX +local function setLabel() + return App.setLabel() end -local function isRxSelectedConnected() - return App.target == App.TARGET_RX and crsf.hasTelemetry +--- Bind is not an event that completes, it is a mode the module enters -- +-- and the other half of the pairing is the user's to do, so the advice +-- belongs before the press rather than after it. +local function pressBind() + lvgl.confirm({ + title = "Bind transmitter?", + message = "The link drops while the transmitter binds. The receiver must be in bind mode too.", + confirm = App.sendBind, + }) end -local function isRxSelectedDisconnected() - return App.target == App.TARGET_RX and not crsf.hasTelemetry +local function pressUnbind() + lvgl.confirm({ + title = "Unbind receiver?", + message = "The link drops and the receiver waits for a bind. Put the transmitter in bind mode to re-pair.", + confirm = App.sendUnbind, + }) +end + +--- One label-and-value row, matching the config tool's info rows. +local function infoRow(parent, title, text) + parent:setting({ + w = FULL_PCT, + title = title, + children = { + { type = lvgl.LABEL, x = LABEL_PCT, text = text }, + }, + }) end local function buildUi() lvgl.clear() + -- The subtitle says what the tool is, not what it is doing: a header + -- that changes meaning is what made the old UID line so hard to read. local pg = lvgl.page({ - title = "ExpressLRS Bind Phrase", - subtitle = App.uidLine, + title = "ExpressLRS", + subtitle = "Bind phrase manager", back = exitTool, }) - local tbox = pg:box({ - w = lvgl.PERCENT_SIZE + 100, + local body = pg:box({ + w = FULL_PCT, flexFlow = lvgl.FLOW_COLUMN, }) - -- ***** Bind Phrase label + text edit + Set button ***** - tbox:setting({ - w = lvgl.PERCENT_SIZE + 100, + -- ***** What to send, and where ***** + body:setting({ + w = FULL_PCT, title = "Bind phrase", children = { { - type = lvgl.BOX, - x = 120 * lvgl.LCD_SCALE, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - children = { - { - type = lvgl.TEXT_EDIT, - w = 250 * lvgl.LCD_SCALE, - value = App.phrase, - -- A phrase must fit one un-chunked MSP_WRITE frame - length = msp.CONST.PHRASE_MAX, - set = function(v) - App.phrase = v - end, - active = App.isTargetReachableOrBoth, - }, - { - type = lvgl.BUTTON, - text = "Set", - press = App.sendSet, - active = isSetEnabled, - }, - }, + type = lvgl.TEXT_EDIT, + x = LABEL_PCT, + w = VALUE_PCT, + value = App.phrase, + -- A phrase must fit one un-chunked MSP_WRITE frame + length = msp.CONST.PHRASE_MAX, + set = function(v) + App.phrase = v + end, }, }, }) - -- ***** Target label + dropdown + Request UID button ***** - tbox:setting({ - w = lvgl.PERCENT_SIZE + 100, - title = "Target", + body:setting({ + w = FULL_PCT, + title = "Apply to", children = { { - type = lvgl.BOX, - x = 120 * lvgl.LCD_SCALE, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_MEDIUM, - children = { - { - type = lvgl.CHOICE, - title = "Select Target", - values = { "Transmitter", "Receiver", "Both" }, - get = function() - return App.target - end, - set = function(n) - App.target = n - end, - }, - { - type = lvgl.BUTTON, - text = "Request UID", - press = App.startUidRequest, - active = App.isTargetReachable, - }, - { - type = lvgl.BUTTON, - text = "Unbind", - press = App.sendUnbind, - visible = isRxSelected, - active = isRxSelectedConnected, - }, - }, + type = lvgl.CHOICE, + x = LABEL_PCT, + title = "Apply to", + values = TARGET_VALUES, + get = function() + return App.target + end, + set = App.setTarget, }, }, }) - -- ***** Show Bind button if RX target selected and no RX connected ***** - pg:box({ - w = lvgl.PERCENT_SIZE + 100, - y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + -- A row of its own: the write is the one action here that changes a + -- device, and its label carries the sequence's progress. + body:button({ + w = FULL_PCT, + text = setLabel, + press = App.sendSet, + active = App.isSetEnabled, + }) + + -- ***** What the devices report back ***** + infoRow(body, "Transmitter", uidText) + infoRow(body, "Receiver", App.receiverText) + + -- ***** Link actions ***** + local actions = body:box({ + w = FULL_PCT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, - align = LEFT, - visible = isRxSelectedDisconnected, - children = { - { - type = lvgl.LABEL, - w = 4 + lvgl.LCD_SCALE * (120 + 250), - text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", - }, - { - type = lvgl.BUTTON, - text = "Bind", - press = App.sendBind, - }, - }, + }) + actions:button({ + w = lvgl.PERCENT_SIZE + 49, + text = "Bind", + press = pressBind, + }) + actions:button({ + w = lvgl.PERCENT_SIZE + 49, + text = "Unbind", + press = pressUnbind, + active = hasLink, }) - -- ***** Bind Phrase History ***** - local histSection = pg:box({ - w = lvgl.PERCENT_SIZE + 100, - y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + -- ***** History, last: it grows and the page scrolls ***** + local histSection = body:box({ + w = FULL_PCT, flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, visible = function() - return App.history.items[1] ~= nil and App.isTargetReachableOrBoth() + return App.history.items[1] ~= nil end, }) histSection:label({ - text = "Bind Phrase History", - w = lvgl.PERCENT_SIZE + 100, + text = "Bind phrase history", + w = FULL_PCT, align = CENTER, }) for i = 1, App.history.MAX do local row = histSection:box({ - w = lvgl.PERCENT_SIZE + 100, + w = FULL_PCT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_SMALL, visible = function() return App.history.items[i] ~= nil end, }) - -- Button containing a history item with its value + -- Fills the phrase field; sending stays with the Set button row:button({ w = lvgl.PERCENT_SIZE + 80, text = function() @@ -189,7 +198,6 @@ local function buildUi() App.useHistory(i) end, }) - -- Button X to delete an item row:button({ text = "X", textColor = COLOR_THEME_WARNING, @@ -241,8 +249,8 @@ end -- ============================================================================ -- Rebuild whenever App.rev moved: TEXT_EDIT's value is a build-time --- snapshot, so a history fill or the Both flow's target flip only shows --- through a fresh build. +-- snapshot, so a history fill only shows through a fresh build. The UID +-- rows, status line and button states are live getters and never need one. function UI.render(_event, _touchState) if UI.builtRev ~= App.rev then buildUi() From 129c6f4eb0f92a371dba54adc3a3776c426447f0 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Fri, 14 Aug 2026 14:33:36 +0300 Subject: [PATCH 187/218] Revert "Rework the bind tool around the transmitter's identity" This reverts commit e7b168061677b358e1a300adc48dbfcc3abf523b. --- README.md | 17 +- docs/development.md | 28 +-- screenshots/tool_bind.png | Bin 33390 -> 25261 bytes screenshots/tool_bind_bw.png | Bin 6404 -> 4138 bytes src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 9 +- src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua | 238 ++++++------------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua | 195 +++++++-------- src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua | 212 ++++++++--------- 8 files changed, 280 insertions(+), 419 deletions(-) diff --git a/README.md b/README.md index 02d0ada..68a847c 100644 --- a/README.md +++ b/README.md @@ -85,19 +85,10 @@ The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS t ## ExpressLRS Bind Phrase Manager The bind tool (`SCRIPTS/TOOLS/ExpressLRSBind/`) sets the bind phrase -- or a raw UID entered as -comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, and can put the -TX in bind mode or unbind a connected receiver. The last five phrases are kept as a pick-and-send -history. - -The transmitter's UID is read back automatically -- when the tool opens, after every write, and -whenever you change the target -- so the page always shows the identity the handset is transmitting -on. The receiver's own UID is not read: a receiver only answers over an active link, and a link only -exists between devices that already share a UID, so its answer could never differ from the -transmitter's. What is worth knowing is whether it is there at all, which the link reports for free. - -Setting the phrase over MSP requires **ExpressLRS 4.1+** on the device; a pre-4.1 module never -answers, and the tool says so in the transmitter row rather than retrying forever. Bind and unbind -are plain CRSF commands and work on every ExpressLRS version. +comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, reads the +current UID back for verification, and can put the TX in bind mode or unbind a connected receiver. +The last five phrases are kept as a pick-and-send history. Setting the phrase over MSP requires +**ExpressLRS 4.1+** on the device. ExpressLRS Bind Phrase Manager
diff --git a/docs/development.md b/docs/development.md index 8213cdc..ba97623 100644 --- a/docs/development.md +++ b/docs/development.md @@ -43,21 +43,14 @@ The configuration tool, `SCRIPTS/TOOLS/ExpressLRS/`: The bind phrase manager, `SCRIPTS/TOOLS/ExpressLRSBind/` (sets the bind phrase / UID over MSP; the device side requires ExpressLRS 4.1+, and a pre-4.1 device simply never answers -- the tool's -bounded UID retry reports that instead of polling forever). - -Only the transmitter's UID is read, and only on the events that can change it: the tool opening, a -write completing, and the target selector moving. The receiver is never asked. It answers over the -link, a link only exists between devices already sharing a UID, so its answer is the transmitter's -number by construction; the link's presence carries the whole of what asking would have told us, and -costs no over-air traffic. That is also why there is no manual refresh -- every event a "read UID" -button existed to cover is one the App observes for itself: +bounded UID retry reports that instead of polling forever): | Module | Purpose | |--------|---------| -| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded transmitter UID probe, and the frame router over `crsf.drain` | +| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded UID read retry, and the frame router over `crsf.drain` | | `history_storage.lua` | The last five phrases, newest first, persisted through `file_storage.lua` as indexed keys `h1`..`h5` | | `ui/lvgl.lua` | Color LCD interface | -| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`). Note `popupConfirmation`'s message argument never reaches the screen on BW: EdgeTX's Lua binding sets `warningInfoText` without `warningInfoLength` (`api_general.cpp` `luaPopupConfirmation`), so a confirmation has only its 24-char title (`WARNING_LINE_LEN`) to say what it needs | +| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`) | Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds. @@ -130,14 +123,13 @@ The simulator supports multiple test scenarios, configurable via the `config.sce ### MSP bind traffic The mock answers the bind tool's MSP `RXTX_CONFIG` traffic: a UID read (`MSP_REQ`) is served from a -per-device `mspUid` table, and a phrase write (`MSP_WRITE`) rederives the target's UID through a -deterministic pseudo-hash, so equal phrases give equal UIDs (the property the Both flow -demonstrates; the bytes need not match the firmware's MD5). Both devices start on the same UID, -because a reachable receiver whose UID differs from the transmitter's is a state the radios cannot -be in. The RX answers only while the scenario keeps it reachable, and writes are unacknowledged just -like the real firmware. `FRAMETYPE_COMMAND` bind requests are log-only -- note that the firmware -handles that command identically at either address (`EnterBindingModeSafely`), so the "unbind" -button puts the receiver into bind mode rather than erasing its binding. +per-device `mspUid` table -- the TX and RX deliberately start with different UIDs so a fresh `normal` +run shows a mismatch that setting both to one phrase visibly fixes -- and a phrase write +(`MSP_WRITE`) rederives the target's UID through a deterministic pseudo-hash, so equal phrases give +equal UIDs (the property the Both flow demonstrates; the bytes need not match the firmware's MD5). +The RX answers only while the scenario keeps it reachable, which is what exercises the tool's +bounded retry, and writes are unacknowledged just like the real firmware. `FRAMETYPE_COMMAND` +bind/unbind requests are log-only. `config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as diff --git a/screenshots/tool_bind.png b/screenshots/tool_bind.png index b3c35e939f3053950256e01888629d03beb72ebe..5083b31e7eb9c018ce2bd406c779b013e5935859 100644 GIT binary patch literal 25261 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 KaO`_4xBtJzf~!gZ diff --git a/screenshots/tool_bind_bw.png b/screenshots/tool_bind_bw.png index 1035bbc1d6d0e4cd3c8b9e18180c47caf5e66c82..488418cba9a85328348b9a4792287df9dc8245bd 100644 GIT binary patch literal 4138 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 b`ia=vx8KlrOX;s0k(GDc)6~2CdmH}?Hj#15 diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index c734f14..f7f7b9c 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -1258,13 +1258,12 @@ end -- MSP bind UID state -- ============================================================================ --- Per-device bind UID, read and written over MSP RXTX_CONFIG. Both start --- on the same UID: a receiver only answers over a link, and a link only --- exists between devices that already share one, so a reachable RX whose --- UID differs from the TX's is a state the radios cannot be in. +-- Per-device bind UID, read and written over MSP RXTX_CONFIG. TX and RX +-- deliberately start different so a fresh "normal" run shows a mismatch +-- that setting both to one phrase visibly fixes. local mspUid = { [CRSF.ADDRESS_TX] = { 13, 213, 105, 32, 0, 1 }, - [CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 1 }, + [CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 2 }, } --- Derive a deterministic 6-byte UID from bind-phrase bytes: the same diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua index 0ee2e85..8c5f806 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -36,42 +36,23 @@ local UID_RETRY_TICKS = 50 -- resend an unanswered UID read after 500 ms local UID_MAX_ATTEMPTS = 6 -- then give up: the device has no MSP config support local FOLLOWUP_TICKS = 100 -- settle time before a follow-up after a write --- How the tool formats the six-byte UID: wide for colour, tight for the --- 128 px BW line where every pixel of the row is spoken for. -local UID_FMT_WIDE = "%d, %d, %d, %d, %d, %d" -local UID_FMT_TIGHT = "%d,%d,%d,%d,%d,%d" - local App = { -- Target selector positions (order matches the UIs' choice lists) TARGET_TX = 1, TARGET_RX = 2, TARGET_BOTH = 3, - -- What is known about the transmitter's UID. Kept apart from the - -- transient status line: an identity is data, an action is an event. - UID_UNKNOWN = 0, -- never asked, or the read was interrupted - UID_OK = 1, -- uid holds the transmitter's answer - UID_SILENT = 2, -- asked and asked again, nothing came back - target = 1, phrase = "", - bothStep = nil, -- which side the in-flight write sequence is on - - -- Only the transmitter is read. A receiver answers over the link, and a - -- link only exists between devices already sharing a UID, so its answer - -- could never be anything but the number below -- while the link's mere - -- existence, which is free, says everything asking would have. + bothStep = nil, -- TARGET_RX while the Both sequence's RX leg is in flight uid = {}, -- reused 6-slot byte table, filled by msp.decodeUid - uidState = 0, - - probing = false, -- a UID read is outstanding - probeAttempts = 0, - setInFlight = false, -- a write sequence owns the defer slot - writeSide = nil, -- side the in-flight write is addressing; drives the Set label - + uidFrom = nil, -- source address of the last UID answer; nil until one lands + statusText = "Idle", -- transient status line; nil shows the UID bytes + uidAttempts = 0, -- Bumped only when a build-time snapshot must refresh (the TEXT_EDIT - -- value). Everything else on the LVGL page reads live getters, and a - -- rebuild resets rotary focus -- so status and UID updates must NOT bump it. + -- value, the CHOICE selection). Everything else on the LVGL page reads + -- live getters, and a rebuild resets rotary focus -- so status and UID + -- updates must NOT bump this. rev = 0, history = History, @@ -98,48 +79,15 @@ function App.isTargetReachableOrBoth() return App.target == App.TARGET_BOTH or App.isTargetReachable() end ---- The transmitter's identity as the UIs display it: the bytes once they --- land, and what is standing in the way until they do. --- @param compact omit the spaces between bytes (the narrow BW line) -function App.uidText(compact) - if App.uidState == App.UID_OK then - local u = App.uid - return string.format(compact and UID_FMT_TIGHT or UID_FMT_WIDE, u[1], u[2], u[3], u[4], u[5], u[6]) - end - if App.probing then - return "Reading..." +--- Status line for the UIs: transient status while an exchange is in +-- flight, then the last UID answer. +function App.uidLine() + if App.statusText then + return App.statusText end - if App.uidState == App.UID_SILENT then - -- The transmitter answers over the handset UART whenever it is - -- powered, so its silence is a firmware statement, not a link problem. - return "Needs ExpressLRS 4.1+" - end - return "--" -end - ---- Whether a receiver is bound to that identity, which is exactly what an --- active link means. -function App.receiverText() - return crsf.hasTelemetry and "Connected" or "Not connected" -end - ---- The Set button's own label, which doubles as the write sequence's --- progress: the report belongs on the control that caused it, so the page --- needs no status line of its own. --- @param compact short forms for the narrow BW row -function App.setLabel(compact) - if App.writeSide == App.TARGET_TX then - return compact and "Setting TX..." or "Setting transmitter..." - end - if App.writeSide == App.TARGET_RX then - return compact and "Setting RX..." or "Setting receiver..." - end - return "Set bind phrase" -end - ---- True while a write sequence is running, which is also what disables Set. -function App.isSetEnabled() - return App.isTargetReachableOrBoth() and App.phrase ~= "" and not App.setInFlight + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d, %d, %d, %d, %d, %d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) end --- Parse one comma-separated segment as a plain decimal byte (surrounding @@ -202,115 +150,86 @@ function App.parseUidText(text) return uid end --- ============================================================================ --- UID probe --- ============================================================================ - ---- Ask the transmitter for its UID, retrying on silence. Bounded: an ELRS --- module without MSP config support (pre-4.1) never answers, and the retry --- must not spam the wire forever. The module answers over the handset UART --- whenever it is powered, so silence here is a firmware statement rather --- than a link problem. -function App.probeStep() - if not App.probing then +--- Request the target's UID, retrying on silence. Bounded: an ELRS device +-- without MSP config support (pre-4.1) never answers, and the retry must +-- not spam the wire forever. +function App.requestUid() + if not App.isTargetReachable() then + App.statusText = "Idle" return end - if App.probeAttempts >= UID_MAX_ATTEMPTS then - App.uidState = App.UID_SILENT - App.probing = false + if App.uidAttempts >= UID_MAX_ATTEMPTS then + App.statusText = "No response (needs ELRS 4.1+)" return end - App.probeAttempts = App.probeAttempts + 1 - crsf.push(msp.encodeUidRead(crsf.CONST.ADDRESS_TX, crsf.CONST.ADDRESS_HANDSET)) - defer.setTimeout(UID_RETRY_TICKS, App.probeStep) + App.uidAttempts = App.uidAttempts + 1 + App.statusText = "Updating..." + local dest = (App.target == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + crsf.push(msp.encodeUidRead(dest, crsf.CONST.ADDRESS_HANDSET)) + defer.setTimeout(UID_RETRY_TICKS, App.requestUid) end -function App.startProbe() - if App.setInFlight then - return - end - App.probing = true - App.probeAttempts = 0 - App.probeStep() +--- User-facing entry point: start a fresh UID request cycle. +function App.startUidRequest() + App.uidAttempts = 0 + App.requestUid() end ---- Drop an in-flight probe so a user action can take the defer slot. -local function cancelProbe() - App.probing = false -end - --- ============================================================================ --- Actions --- ============================================================================ - ---- Point the tool at a different side. Also the natural retry: a user who --- moves the selector after a silent read gets a fresh look at the module. -function App.setTarget(n) - if n == App.target then +--- Send the phrase (or raw UID) to the selected target. "Both" is a +-- two-step sequence: the RX first -- writing its phrase drops it off the +-- link -- then the TX, after which the selector rests on Transmitter. +function App.sendSet() + if App.phrase == "" then return end - App.target = n - App.startProbe() -end - ---- The write sequence's last step: release the defer slot and read back --- what the transmitter actually stored. -local function finishSet() - App.setInFlight = false - App.bothStep = nil - App.writeSide = nil - App.startProbe() -end - ---- Write the phrase (or raw UID) to the side the sequence is on, then --- either advance to the transmitter or finish. -function App.writeStep() - local side = App.bothStep - local isTx = side == App.TARGET_TX - App.writeSide = side - local dest = isTx and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + if App.target == App.TARGET_BOTH then + if App.bothStep == nil then + App.bothStep = App.TARGET_RX + else + App.bothStep = nil + -- The selector visibly rests on Transmitter after the sequence; the + -- CHOICE renders its build-time selection, so this needs a rebuild. + App.target = App.TARGET_TX + App.rev = App.rev + 1 + end + end + local effective = App.bothStep or App.target + if App.bothStep == nil then + App.statusText = (App.target == App.TARGET_TX) and "Setting transmitter..." or "Setting receiver..." + else + App.statusText = "Setting RX and disconnecting..." + end + local dest = (effective == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX local uid = App.parseUidText(App.phrase) if uid then crsf.push(msp.encodeUidWrite(dest, crsf.CONST.ADDRESS_HANDSET, uid)) else crsf.push(msp.encodePhraseWrite(dest, crsf.CONST.ADDRESS_HANDSET, App.phrase)) end - if App.target == App.TARGET_BOTH and not isTx then - App.bothStep = App.TARGET_TX - defer.setTimeout(FOLLOWUP_TICKS, App.writeStep) + History.add(App.phrase) + if App.bothStep == nil then + defer.setTimeout(FOLLOWUP_TICKS, App.startUidRequest) else - defer.setTimeout(FOLLOWUP_TICKS, finishSet) + defer.setTimeout(FOLLOWUP_TICKS, App.sendSet) end end ---- Send the phrase to the selected side. "Both" runs as a two-step --- sequence: the receiver first -- writing its phrase drops it off the link --- -- then the transmitter, which brings the link back on the new identity. -function App.sendSet() - if not App.isSetEnabled() then - return - end - cancelProbe() - App.setInFlight = true - App.bothStep = (App.target == App.TARGET_BOTH) and App.TARGET_RX or App.target - History.add(App.phrase) - App.writeStep() +local function markSent() + App.statusText = "Sent" end ---- Put the TX module in bind mode, meeting an RX that is in bind mode too --- (firmware: both addresses of this command reach EnterBindingModeSafely). --- A plain CRSF command, so unlike the phrase write it works on every ELRS --- version -- which is what keeps the tool useful on a pre-4.1 setup. --- Neither side reports its bind state back, so the UIs say what the user --- still has to do rather than claiming a result. +--- Put the TX module in bind mode, catching an RX waiting in bind mode. function App.sendBind() + App.statusText = "Sending bind command..." crsf:sendBindCommand(crsf.CONST.ADDRESS_TX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) end ---- Send the same command to the connected receiver, which drops the link --- and leaves it listening for a bind. +--- Unbind the connected receiver. function App.sendUnbind() + App.statusText = "Sending unbind to RX..." crsf:sendBindCommand(crsf.CONST.ADDRESS_RX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) end function App.useHistory(i) @@ -331,22 +250,19 @@ function App.clearHistory() end --- Frame router for crsf.drain(): the tool is the sole drainer in its Lua --- state, and the only traffic it consumes is the transmitter's MSP UID --- answer. Source gating lives here, as msp.lua leaves it to the caller. +-- state, and the only traffic it consumes is the MSP UID answer. function App.onFrame(_consumer, command, data) - if command ~= crsf.CONST.FRAMETYPE_MSP_RESP or data[2] ~= crsf.CONST.ADDRESS_TX then + if command ~= crsf.CONST.FRAMETYPE_MSP_RESP then return end - if msp.decodeUid(data, App.uid) == nil then + local srcId = msp.decodeUid(data, App.uid) + if srcId == nil then return end - App.uidState = App.UID_OK - -- Only an answer we are waiting for ends the retry; a late duplicate - -- must not cancel a write sequence's follow-up. - if App.probing then - defer.clear() - App.probing = false - end + defer.clear() + App.uidFrom = srcId + App.statusText = nil + App.uidAttempts = 0 end -- ============================================================================ @@ -374,8 +290,8 @@ local function init() end UI.init() -- One tick of delay so run()'s first drain creates the telemetry queue - -- before the answer can land. - defer.setTimeout(1, App.startProbe) + -- before the request's answer can land. + defer.setTimeout(1, App.startUidRequest) -- The returned table stays on the standalone Lua stack and pins init(), -- which holds VERSION and useLvgl as upvalues. Drop it. M.init = nil diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua index 5440a80..56c2647 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -17,12 +17,12 @@ local drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() -- UI state -- ============================================================================ --- Cursor stop ids (numeric to save RAM). History items are HIST_BASE + i. +-- Row ids (numeric to save RAM). History items are encoded as HIST_BASE + i. local ROW_PHRASE = 1 local ROW_TARGET = 2 -local ROW_SET = 3 -local ROW_BIND = 4 -local ROW_UNBIND = 5 +local ROW_UID = 3 +local ROW_SET = 4 +local ROW_BIND = 5 -- renders Bind or Unbind depending on the link local ROW_HISTORY = 6 local ROW_EXIT = 7 local ROW_CLEAR = 8 @@ -44,31 +44,23 @@ local UI = { ---@type table constructed in init(), needs LCD_W for the window phraseEdit = nil, - -- Pending confirmation popup: { msg, info, fn }. Only msg reaches the - -- screen: EdgeTX's Lua binding sets warningInfoText without - -- warningInfoLength, so the BW popup draws the info line zero chars wide - -- (api_general.cpp luaPopupConfirmation). Keep msg under 24 chars -- - -- WARNING_LINE_LEN -- and say what matters there. + -- Pending confirmation popup: { msg, i (history index) or nil = clear all } confirm = nil, - -- Layout constants for 128x64; UI.init widens COL2 at 212 px wide + -- Layout constants for 128x64; UI.init widens COL2 at 212px wide COL1 = 0, - COL2 = 54, + COL2 = 70, textSize = 8, textYoffset = 3, - ---@type table row baselines, filled by init() for the screen height - y = nil, - -- Redraw state: the page repaints on events and whenever a live part of - -- it changes (statusKey below), tracked as a string compare per frame. + -- Redraw state: the page repaints on events and whenever the live status + -- line changes (statusLine below), tracked as a string compare per frame. forceRedraw = true, lastStatus = nil, } --- 128x64 has room for three normal rows, the two report lines and one --- action row, which is why the three actions share a line at SMLSIZE. -local Y_SHORT = { phrase = 9, target = 17, set = 25, tx = 34, rx = 41, actions = 48, exit = 56 } -local Y_TALL = { phrase = 10, target = 20, set = 30, tx = 44, rx = 52, actions = 62, exit = 76 } +-- Both pages fit the 8-row 128x64 grid (main 7 rows, history at most 7), +-- so there is no scrolling and no page offset in this UI. -- ============================================================================ -- Interface: init @@ -78,7 +70,6 @@ function UI.init() if LCD_W == 212 then UI.COL2 = 110 end - UI.y = (LCD_H >= 96) and Y_TALL or Y_SHORT -- The phrase window ends at the cursor; 6 px per char of the fixed BW font UI.phraseEdit = TextEdit.new(msp.CONST.PHRASE_MAX, math.floor((LCD_W - UI.COL2 - 2) / 6)) @@ -122,7 +113,7 @@ function UI.handleNoModule() end -- ============================================================================ --- Cursor stops +-- Row list -- ============================================================================ local function buildRows() @@ -133,12 +124,10 @@ local function buildRows() if UI.page == PAGE_MAIN then rows[1] = ROW_PHRASE rows[2] = ROW_TARGET - rows[3] = ROW_SET - rows[4] = ROW_BIND - -- Unbind appears only with a receiver to unbind, so the cursor never - -- lands on a dead action -- and Bind keeps its place either way. - if crsf.hasTelemetry then - rows[#rows + 1] = ROW_UNBIND + rows[3] = ROW_UID + rows[4] = ROW_SET + if App.target == App.TARGET_RX then + rows[5] = ROW_BIND end rows[#rows + 1] = ROW_HISTORY rows[#rows + 1] = ROW_EXIT @@ -154,6 +143,16 @@ local function buildRows() end end +--- The UID/status row text: compact so it fits 128 px at SMLSIZE. +local function statusLine() + if App.statusText then + return App.statusText + end + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d,%d,%d,%d,%d,%d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) +end + -- ============================================================================ -- Navigation and actions -- ============================================================================ @@ -177,25 +176,30 @@ end local function handleEnter(id) if id == ROW_PHRASE then - UI.phraseEdit.value = App.phrase - UI.phraseEdit:start() + if App.isTargetReachableOrBoth() then + UI.phraseEdit.value = App.phrase + UI.phraseEdit:start() + end elseif id == ROW_TARGET then UI.editTarget = true + elseif id == ROW_UID then + App.startUidRequest() elseif id == ROW_SET then - App.sendSet() + if App.isTargetReachableOrBoth() and App.phrase ~= "" then + App.sendSet() + end elseif id == ROW_BIND then - -- Bind is a mode the module enters, not an event that completes, and - -- the other half of the pairing is the user's to do -- so the advice - -- belongs before the press, not after it. - UI.confirm = { msg = "Bind transmitter?", info = "Also bind the RX", fn = App.sendBind } - elseif id == ROW_UNBIND then - UI.confirm = { msg = "Unbind receiver?", info = "Link will drop", fn = App.sendUnbind } + if crsf.hasTelemetry then + App.sendUnbind() + else + App.sendBind() + end elseif id == ROW_HISTORY then goToPage(PAGE_HISTORY) elseif id == ROW_EXIT then App.shouldExit = true elseif id == ROW_CLEAR then - UI.confirm = { msg = "Clear history?", info = "[OK] to confirm", fn = App.clearHistory } + UI.confirm = { msg = "Clear history?" } elseif id == ROW_BACK then goToPage(PAGE_MAIN) elseif id > HIST_BASE then @@ -217,9 +221,9 @@ local function handleEvent(event) -- Target edit: rotary cycles, ENTER or EXIT commits if UI.editTarget then if event == EVT_VIRTUAL_NEXT then - App.setTarget(math.min(App.target + 1, App.TARGET_BOTH)) + App.target = math.min(App.target + 1, App.TARGET_BOTH) elseif event == EVT_VIRTUAL_PREV then - App.setTarget(math.max(App.target - 1, App.TARGET_TX)) + App.target = math.max(App.target - 1, App.TARGET_TX) elseif event == EVT_VIRTUAL_ENTER or event == EVT_VIRTUAL_EXIT then UI.editTarget = nil end @@ -238,14 +242,7 @@ local function handleEvent(event) killEvents(event) local id = UI.rows[UI.lineIndex] if id and id > HIST_BASE then - local i = id - HIST_BASE - UI.confirm = { - msg = "Delete entry?", - info = "[OK] to confirm", - fn = function() - App.removeHistory(i) - end, - } + UI.confirm = { msg = "Delete entry?", i = id - HIST_BASE } end elseif event == EVT_VIRTUAL_NEXT then selectRow(1) @@ -265,56 +262,33 @@ local function drawTitle() lcd.drawText(LCD_W - 1, 1, crsf.hasTelemetry and "C" or "-", INVERS + RIGHT) end -local function attrFor(id) - return (UI.rows[UI.lineIndex] == id) and INVERS or 0 -end - -local function drawMain() - local y = UI.y - - lcd.drawText(UI.COL1, y.phrase, "Phrase", 0) - UI.phraseEdit:draw(UI.COL2, y.phrase, attrFor(ROW_PHRASE)) - - local targetAttr = attrFor(ROW_TARGET) - if UI.editTarget then - targetAttr = targetAttr + BLINK - end - lcd.drawText(UI.COL1, y.target, "Apply to", 0) - lcd.drawText(UI.COL2, y.target, TARGET_NAMES[App.target], targetAttr) - - -- The one action that changes a device: its own row, and its label - -- doubles as the write sequence's progress - local setAttr = attrFor(ROW_SET) - if App.isSetEnabled() then - setAttr = setAttr + BOLD - end - lcd.drawText(LCD_W / 2, y.set, "[" .. App.setLabel(true) .. "]", setAttr + CENTER) - - -- What the devices report back - lcd.drawText(UI.COL1, y.tx, "TX " .. App.uidText(true), SMLSIZE) - lcd.drawText(UI.COL1, y.rx, "RX " .. App.receiverText(), SMLSIZE) - - lcd.drawText(UI.COL1 + 1, y.actions, "[Bind]", attrFor(ROW_BIND) + SMLSIZE) - if crsf.hasTelemetry then - lcd.drawText(math.floor(LCD_W * 0.3), y.actions, "[Unbind]", attrFor(ROW_UNBIND) + SMLSIZE) - end - lcd.drawText(LCD_W - 1, y.actions, "[History]", attrFor(ROW_HISTORY) + SMLSIZE + RIGHT) - - lcd.drawText(LCD_W / 2, y.exit, "[---- EXIT ----]", attrFor(ROW_EXIT) + BOLD + CENTER) -end - -local function drawHistory() - for i = 1, #UI.rows do - local id = UI.rows[i] - local yPos = i * UI.textSize + UI.textYoffset - local attr = (UI.lineIndex == i) and INVERS or 0 - if id == ROW_CLEAR then - lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) - elseif id == ROW_BACK then - lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) - else - lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) +local function drawRow(id, yPos, isSelected) + local attr = isSelected and INVERS or 0 + if id == ROW_PHRASE then + lcd.drawText(UI.COL1, yPos, "Phrase", 0) + UI.phraseEdit:draw(UI.COL2, yPos, attr) + elseif id == ROW_TARGET then + if UI.editTarget and isSelected then + attr = attr + BLINK end + lcd.drawText(UI.COL1, yPos, "Target", 0) + lcd.drawText(UI.COL2, yPos, TARGET_NAMES[App.target], attr) + elseif id == ROW_UID then + lcd.drawText(UI.COL1, yPos, statusLine(), attr + SMLSIZE) + elseif id == ROW_SET then + lcd.drawText(10, yPos, "[Set]", attr + BOLD) + elseif id == ROW_BIND then + lcd.drawText(10, yPos, crsf.hasTelemetry and "[Unbind]" or "[Bind]", attr + BOLD) + elseif id == ROW_HISTORY then + lcd.drawText(UI.COL1, yPos, "> History", attr + BOLD) + elseif id == ROW_EXIT then + lcd.drawText(10, yPos, "[---- EXIT ----]", attr + BOLD) + elseif id == ROW_CLEAR then + lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) + elseif id == ROW_BACK then + lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) + elseif id > HIST_BASE then + lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) end end @@ -325,10 +299,8 @@ local function drawPage(event) lcd.clear() drawTitle() - if UI.page == PAGE_MAIN then - drawMain() - else - drawHistory() + for i = 1, #UI.rows do + drawRow(UI.rows[i], i * UI.textSize + UI.textYoffset, UI.lineIndex == i) end end @@ -336,19 +308,12 @@ end -- Interface: render -- ============================================================================ ---- Everything on the page that can change without an event: repainting on --- a change keeps the UID rows and status live without repainting at frame --- rate. -local function statusKey() - return App.setLabel(true) .. App.uidText(true) .. (crsf.hasTelemetry and "C" or "-") -end - function UI.render(event, _touchState) -- Pending confirmation owns the screen until answered. It arms only after - -- a quiet frame: the ENTER release that follows the press which opened it - -- would otherwise answer it on the spot. + -- a quiet frame: the ENTER release that follows the long press which + -- opened it would otherwise answer it on the spot. if UI.confirm then - local result = popupConfirmation(UI.confirm.msg, UI.confirm.info, event) + local result = popupConfirmation(UI.confirm.msg, "PRESS [OK] to confirm", event) if not UI.confirm.armed then if event == 0 then UI.confirm.armed = true @@ -356,7 +321,11 @@ function UI.render(event, _touchState) return end if result == "OK" then - UI.confirm.fn() + if UI.confirm.i then + App.removeHistory(UI.confirm.i) + else + App.clearHistory() + end UI.confirm = nil UI.forceRedraw = true elseif result == "CANCEL" then @@ -366,7 +335,9 @@ function UI.render(event, _touchState) return end - local status = statusKey() + -- Repaint on any event, while editing, and whenever the live parts of the + -- page (status line, link flag) changed since the last paint. + local status = statusLine() .. (crsf.hasTelemetry and "C" or "-") if event ~= 0 or UI.forceRedraw or UI.phraseEdit.editing or status ~= UI.lastStatus then drawPage(event) UI.lastStatus = status diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua index a134279..3b45b45 100644 --- a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -22,14 +22,6 @@ local UI = { dialogBuilt = false, } --- Same split the config tool uses: label column, value column. -local IS_NARROW = LCD_W < 400 -local LABEL_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 42 or 50) -local VALUE_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 58 or 50) -local FULL_PCT = lvgl.PERCENT_SIZE + 100 - -local TARGET_VALUES = { "Transmitter", "Receiver", "Both" } - -- ============================================================================ -- Main page -- ============================================================================ @@ -38,157 +30,156 @@ local function exitTool() App.shouldExit = true end -local function hasLink() - return crsf.hasTelemetry -end - --- Own wrappers: lvgl calls a text getter with no arguments, and both --- getters take a compact-format flag as their first parameter. -local function uidText() - return App.uidText() +local function isSetEnabled() + return App.isTargetReachableOrBoth() and App.phrase ~= "" end -local function setLabel() - return App.setLabel() +local function isRxSelected() + return App.target == App.TARGET_RX end ---- Bind is not an event that completes, it is a mode the module enters -- --- and the other half of the pairing is the user's to do, so the advice --- belongs before the press rather than after it. -local function pressBind() - lvgl.confirm({ - title = "Bind transmitter?", - message = "The link drops while the transmitter binds. The receiver must be in bind mode too.", - confirm = App.sendBind, - }) +local function isRxSelectedConnected() + return App.target == App.TARGET_RX and crsf.hasTelemetry end -local function pressUnbind() - lvgl.confirm({ - title = "Unbind receiver?", - message = "The link drops and the receiver waits for a bind. Put the transmitter in bind mode to re-pair.", - confirm = App.sendUnbind, - }) -end - ---- One label-and-value row, matching the config tool's info rows. -local function infoRow(parent, title, text) - parent:setting({ - w = FULL_PCT, - title = title, - children = { - { type = lvgl.LABEL, x = LABEL_PCT, text = text }, - }, - }) +local function isRxSelectedDisconnected() + return App.target == App.TARGET_RX and not crsf.hasTelemetry end local function buildUi() lvgl.clear() - -- The subtitle says what the tool is, not what it is doing: a header - -- that changes meaning is what made the old UID line so hard to read. local pg = lvgl.page({ - title = "ExpressLRS", - subtitle = "Bind phrase manager", + title = "ExpressLRS Bind Phrase", + subtitle = App.uidLine, back = exitTool, }) - local body = pg:box({ - w = FULL_PCT, + local tbox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, }) - -- ***** What to send, and where ***** - body:setting({ - w = FULL_PCT, + -- ***** Bind Phrase label + text edit + Set button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, title = "Bind phrase", children = { { - type = lvgl.TEXT_EDIT, - x = LABEL_PCT, - w = VALUE_PCT, - value = App.phrase, - -- A phrase must fit one un-chunked MSP_WRITE frame - length = msp.CONST.PHRASE_MAX, - set = function(v) - App.phrase = v - end, + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.TEXT_EDIT, + w = 250 * lvgl.LCD_SCALE, + value = App.phrase, + -- A phrase must fit one un-chunked MSP_WRITE frame + length = msp.CONST.PHRASE_MAX, + set = function(v) + App.phrase = v + end, + active = App.isTargetReachableOrBoth, + }, + { + type = lvgl.BUTTON, + text = "Set", + press = App.sendSet, + active = isSetEnabled, + }, + }, }, }, }) - body:setting({ - w = FULL_PCT, - title = "Apply to", + -- ***** Target label + dropdown + Request UID button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Target", children = { { - type = lvgl.CHOICE, - x = LABEL_PCT, - title = "Apply to", - values = TARGET_VALUES, - get = function() - return App.target - end, - set = App.setTarget, + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.CHOICE, + title = "Select Target", + values = { "Transmitter", "Receiver", "Both" }, + get = function() + return App.target + end, + set = function(n) + App.target = n + end, + }, + { + type = lvgl.BUTTON, + text = "Request UID", + press = App.startUidRequest, + active = App.isTargetReachable, + }, + { + type = lvgl.BUTTON, + text = "Unbind", + press = App.sendUnbind, + visible = isRxSelected, + active = isRxSelectedConnected, + }, + }, }, }, }) - -- A row of its own: the write is the one action here that changes a - -- device, and its label carries the sequence's progress. - body:button({ - w = FULL_PCT, - text = setLabel, - press = App.sendSet, - active = App.isSetEnabled, - }) - - -- ***** What the devices report back ***** - infoRow(body, "Transmitter", uidText) - infoRow(body, "Receiver", App.receiverText) - - -- ***** Link actions ***** - local actions = body:box({ - w = FULL_PCT, + -- ***** Show Bind button if RX target selected and no RX connected ***** + pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_MEDIUM, - }) - actions:button({ - w = lvgl.PERCENT_SIZE + 49, - text = "Bind", - press = pressBind, - }) - actions:button({ - w = lvgl.PERCENT_SIZE + 49, - text = "Unbind", - press = pressUnbind, - active = hasLink, + align = LEFT, + visible = isRxSelectedDisconnected, + children = { + { + type = lvgl.LABEL, + w = 4 + lvgl.LCD_SCALE * (120 + 250), + text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", + }, + { + type = lvgl.BUTTON, + text = "Bind", + press = App.sendBind, + }, + }, }) - -- ***** History, last: it grows and the page scrolls ***** - local histSection = body:box({ - w = FULL_PCT, + -- ***** Bind Phrase History ***** + local histSection = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, visible = function() - return App.history.items[1] ~= nil + return App.history.items[1] ~= nil and App.isTargetReachableOrBoth() end, }) histSection:label({ - text = "Bind phrase history", - w = FULL_PCT, + text = "Bind Phrase History", + w = lvgl.PERCENT_SIZE + 100, align = CENTER, }) for i = 1, App.history.MAX do local row = histSection:box({ - w = FULL_PCT, + w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_SMALL, visible = function() return App.history.items[i] ~= nil end, }) - -- Fills the phrase field; sending stays with the Set button + -- Button containing a history item with its value row:button({ w = lvgl.PERCENT_SIZE + 80, text = function() @@ -198,6 +189,7 @@ local function buildUi() App.useHistory(i) end, }) + -- Button X to delete an item row:button({ text = "X", textColor = COLOR_THEME_WARNING, @@ -249,8 +241,8 @@ end -- ============================================================================ -- Rebuild whenever App.rev moved: TEXT_EDIT's value is a build-time --- snapshot, so a history fill only shows through a fresh build. The UID --- rows, status line and button states are live getters and never need one. +-- snapshot, so a history fill or the Both flow's target flip only shows +-- through a fresh build. function UI.render(_event, _touchState) if UI.builtRev ~= App.rev then buildUi() From 5f6a01546aba9dc82c3392d258be01f4a41a96e3 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 19:29:37 +0300 Subject: [PATCH 188/218] Keep asking for model match while the mismatch stands The module recomputes the flag as it answers and never sends an ELRS_STATUS unasked, so the one-answer-per-connection latch only reset on a hasTelemetry edge -- and every way out of a mismatch is applied over a link the handset never sees drop. The warning stayed on screen for the rest of the session. The request rules now read as one predicate, canRequestStatus(): connected, ELRS module, no answer yet or the last one was a mismatch, RSSI above -70, at most once per second. Only the third rule changed. The RSSI gate still covers the retry, so a mismatch fixed out at range keeps its banner until the link drops. ELRSTelemetry create() drops the verdict too: instances are rebuilt per model, but the singleton they share is not. The new dev-only mismatch_recovery scenario clears the flag with RQly held at 95, which no existing scenario did. Over 45 s with 5 telemetry + 12 VTX instances: normal and weak_link still write zero status requests, mismatch_recovery 11 at 1 Hz and then silence. --- docs/development.md | 2 +- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 34 +++++++++- src/SCRIPTS/ELRS/crsf_elrsinfo.lua | 72 +++++++++++++++------ src/WIDGETS/ELRSTelemetry/main.lua | 6 ++ 4 files changed, 89 insertions(+), 25 deletions(-) diff --git a/docs/development.md b/docs/development.md index ba97623..c65300e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | -| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget | +| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, model-match status and the rules for when it may be asked for. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index f7f7b9c..f974f40 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -28,9 +28,15 @@ local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() -- "model_mismatch" TX + RX connected but with Model ID mismatch flag set. -- Triggers the Model Mismatch warning dialog. -- "mismatch_cycle" Model-mismatch link that drops and returns (~10 s up, --- ~5 s down, forever). Exercises the per-connection status --- poll latch: one ELRS_STATUS request per connected phase, --- and the mismatch warning must clear while the link is down. +-- ~5 s down, forever). One ELRS_STATUS request on each +-- connect edge and then ~1 Hz for as long as the mismatch +-- stands; the warning must clear while the link is down. +-- "mismatch_recovery" TX + RX connected on a strong link with the model +-- mismatch flag set, clearing ~10 s in while RQly never +-- leaves 95. The transition a once-per-connection status +-- latch can never observe: the module only reports the +-- verdict when asked, so the warning clears only if the +-- widget keeps asking while it stands. -- "weak_link" TX + RX connected on a marginal link (RQly ~60, RSSI -- ~-85 dBm). Both the model-match poll and the VTX Admin -- folder poll must stay quiet here. @@ -1105,6 +1111,10 @@ updateTlmBandwidth(txDevice) local reconnectDelay = 500 -- ~5 seconds (getTime() ticks at 10ms) local startTime = nil -- set on first mockPush/mockPop call +-- Mismatch recovery scenario timing: how long the mismatch stands before the +-- module starts answering that it is gone, with the link untouched throughout. +local mismatchClearDelay = 1000 -- ~10 seconds (getTime() ticks at 10ms) + -- Dynamic RX availability (replaces static hasRxDevice boolean) local function isRxAvailable() if config.scenario == "reconnect" then @@ -1141,6 +1151,11 @@ local function getElrsFlags() flags = isRxAvailable() and 0x01 or 0x00 elseif config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then flags = 0x05 -- connected + model mismatch + elseif config.scenario == "mismatch_recovery" then + -- Recomputed per answer, exactly as sendELRSstatus() does: the mismatch + -- ends on its own with the link still up, and nothing announces it. + local cleared = startTime ~= nil and getTime() - startTime >= mismatchClearDelay + flags = cleared and 0x01 or 0x05 elseif config.scenario == "armed" then flags = 0x09 -- connected + armed elseif config.scenario == "critical_error" then @@ -1676,6 +1691,19 @@ local scenarioTelemetry = { RxBt = 15.8, Curr = 0.5, }, + -- Same signal as model_mismatch, and deliberately no sensorToggle entry: the + -- mismatch bit is the only thing that may move, so RQly stays at 95 and the + -- connection never edges. + mismatch_recovery = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + ANT = 1, + RxBt = 15.8, + Curr = 0.5, + }, -- Marginal link: RQly never 0 and never above 90, active-antenna RSSI never -- above -70 dBm, so every gated poll must stay quiet. weak_link = { diff --git a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua index 3c453ed..f1676a4 100644 --- a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua +++ b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua @@ -3,7 +3,7 @@ -- -- -- Opt-in stateful companion to the CRSF singleton: DEVICE_INFO cache -- -- (module name, version-keyed RFMOD/RFRSSI lookup tables) and the -- --- per-connection model-match status, fed by drain(). Loaded once per -- +-- model-match status, fed by drain(). Loaded once per -- -- Lua state and shared by every widget instance; widgets that do not -- -- need this data never load it, so they never pay for the tables. -- -- -- @@ -24,9 +24,9 @@ local ElrsInfo = {} -- name, isElrs, RFMOD, RFRSSI ElrsInfo.deviceInfo = {} --- Model match comes from the ELRS_STATUS answer updateModelMatch() requests --- once per connection: a connect-time snapshot, fine for model match, which --- is decided at connect. +-- Model match comes from the ELRS_STATUS answers updateModelMatch() asks for. +-- The module recomputes the flag as it answers and never sends one unasked, so +-- the value is exactly as old as the last answer. ---@type boolean? ElrsInfo.modelMismatch = nil @@ -36,9 +36,9 @@ ElrsInfo._lastDevPoll = 0 -- ELRS status polling ElrsInfo._lastStatusPoll = 0 --- Set once the current connection's ELRS_STATUS answer has arrived, so each --- connection is polled for model match at most once. Cleared with the rest of --- the per-connection state on any hasTelemetry edge in update(). +-- Set once an ELRS_STATUS answer has arrived, so a match is asked about once +-- rather than polled. Cleared with the rest of the per-connection state on any +-- hasTelemetry edge in update(), and by resetModelMatch() on widget create. ---@type boolean? ElrsInfo._statusAnswered = nil @@ -82,33 +82,63 @@ local function requestDeviceInfo() crsf:pingDevices(crsf.CONST.ADDRESS_TX) end --- Weakest link updateModelMatch() will spend a frame on: a mismatch is caught --- next to the quad, and on a marginal link every RC-channels frame matters. +-- Weakest link a status request will be spent on (rule 4 below). local MODEL_MATCH_MIN_RSSI = -70 ---- Keep modelMismatch current at about one status request per connection. --- requestElrsStatus() is answered locally, but still replaces one RC-channels --- frame on the handset->module UART, so it is sent only when it can matter --- and can be afforded: while connected, from a module that identifies as --- ExpressLRS (other CRSF modules never answer the fieldId=0 convention), on --- a strong link, and only until the current connection's answer arrives. --- Retries at most once per second while unanswered. -local function updateModelMatch() - if not (crsf.hasTelemetry and ElrsInfo.deviceInfo.isElrs) or ElrsInfo._statusAnswered then - return +--- Whether a status request is worth the frame it costs, right now. +-- requestElrsStatus() is answered locally by the module, but the push still +-- replaces one RC-channels frame on the handset->module UART, so every rule +-- below weighs one answer against one frame. All of them must hold. +local function canRequestStatus(now) + -- 1. Connected. The module reports no model-match verdict without a link, + -- and the answer would carry nothing to display. + if not crsf.hasTelemetry then + return false + end + -- 2. ExpressLRS. The fieldId=0 request is an ELRS convention; a TBS module + -- answers device pings but never this, and would be asked forever. + if not ElrsInfo.deviceInfo.isElrs then + return false end + -- 3. No answer yet, or the last one was a mismatch. One answer settles a + -- match, but the module recomputes the flag as it answers and announces + -- nothing on its own, while every way out of a mismatch -- receiver + -- number, model select, the tool's Model Match switch, a rebind -- is + -- applied over a link the handset never sees drop. Latching there would + -- pin the warning on screen for the rest of the session. + if ElrsInfo._statusAnswered and not ElrsInfo.modelMismatch then + return false + end + -- 4. Strong link. A mismatch is caught next to the quad; out at range every + -- RC-channels frame is worth more than the answer is. local rssi = getActiveRssi() if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then - return + return false end + -- 5. At most once per second, which is as fresh as an answer gets: the + -- module latches its own packet counters on a 1 s watchdog. + return now - ElrsInfo._lastStatusPoll >= 100 +end + +--- Keep modelMismatch current, one request per second at the very most. +local function updateModelMatch() local now = getTime() - if now - ElrsInfo._lastStatusPoll < 100 then + if not canRequestStatus(now) then return end ElrsInfo._lastStatusPoll = now crsf:requestElrsStatus() end +--- Forget the model-match verdict, so the next tick asks for a fresh one. +-- Called from widget create(), which runs again for every instance of every +-- model; this singleton outlives them, so without it the previous model's +-- verdict carries over. deviceInfo stays -- the module did not change. +function ElrsInfo:resetModelMatch() + self.modelMismatch = nil + self._statusAnswered = nil +end + --- Per-tick pump; call right after drain() so the tick's frames are -- ingested and hasTelemetry is current. Any hasTelemetry edge wipes the -- per-connection state before updateModelMatch() runs: a stale diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index ee119c2..ca500f3 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -21,6 +21,12 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _elrsInfoSingleton = getElrsInfo(_crsfSingleton) end + -- Every model has its own widgets, so create() runs again for each of them + -- on a model change -- but the singletons above do not go with them. They + -- are globals in the widget Lua state, which only boot and resume from + -- shutdown rebuild, so the verdict the previous model's link gave would + -- carry into this one. Drop it here and ask again. + _elrsInfoSingleton:resetModelMatch() local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") ---@diagnostic disable-next-line: need-check-nil return loadable(zone, options, _crsfSingleton, _elrsInfoSingleton) From e277f646208bc99128081fedfea242d8457d4ceb Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:09:18 +0300 Subject: [PATCH 189/218] Fold the shared elrsinfo module into the telemetry widget crsf_elrsinfo.lua was written on the assumption that TX-module state would be useful to more than one consumer. It never was: every one of its five API members has exactly one reader, and that reader is the telemetry widget. Keeping it in SCRIPTS/ELRS taxed every reader of the shared library with a module that is not shared, and the library table already had to say "loaded only by the telemetry widget" to explain it. Pure path move. It stays a singleton behind the _elrsInfoSingleton global in main.lua, because its poll rate limits are one set of counters: loaded per instance it would ping and ask for model match once per second per placed widget, and every push costs an RC-channels frame. --- README.md | 2 +- docs/development.md | 1 - .../ELRSTelemetry/elrsinfo.lua} | 12 +++++++----- src/WIDGETS/ELRSTelemetry/main.lua | 4 ++-- 4 files changed, 10 insertions(+), 9 deletions(-) rename src/{SCRIPTS/ELRS/crsf_elrsinfo.lua => WIDGETS/ELRSTelemetry/elrsinfo.lua} (95%) diff --git a/README.md b/README.md index 68a847c..8cf4143 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,6 @@ SCRIPTS/ crsf.lua -- shared CRSF protocol library crsf_params.lua -- parameter codec (tool, VTX Admin) crsf_session.lua -- stateful parameter client (tool, VTX Admin) - crsf_elrsinfo.lua -- TX module info state (telemetry widget) msp.lua -- MSP-over-CRSF codec (bind tool) defer.lua -- deferred-callback timer (bind tool) loader.lua -- GC-guarded script loader (both tools) @@ -51,6 +50,7 @@ SCRIPTS/ WIDGETS/ ELRSTelemetry/ main.lua + elrsinfo.lua -- TX module info state loadable.lua ui/ ... diff --git a/docs/development.md b/docs/development.md index c65300e..fb9897d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -62,7 +62,6 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | | `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | | `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | -| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, model-match status and the rules for when it may be asked for. Loaded only by the telemetry widget | | `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | | `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | | `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | diff --git a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua b/src/WIDGETS/ELRSTelemetry/elrsinfo.lua similarity index 95% rename from src/SCRIPTS/ELRS/crsf_elrsinfo.lua rename to src/WIDGETS/ELRSTelemetry/elrsinfo.lua index f1676a4..9f0adb7 100644 --- a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua +++ b/src/WIDGETS/ELRSTelemetry/elrsinfo.lua @@ -1,11 +1,13 @@ --------------------------------------------------------------------------- -- ELRS TX Module Info -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- +-- returns the ElrsInfo singleton. -- -- -- --- Opt-in stateful companion to the CRSF singleton: DEVICE_INFO cache -- --- (module name, version-keyed RFMOD/RFRSSI lookup tables) and the -- --- model-match status, fed by drain(). Loaded once per -- --- Lua state and shared by every widget instance; widgets that do not -- --- need this data never load it, so they never pay for the tables. -- +-- Stateful companion to the CRSF singleton: DEVICE_INFO cache (module -- +-- name, version-keyed RFMOD/RFRSSI lookup tables) and the model-match -- +-- status, fed by drain(). Loaded once per Lua state and shared by every -- +-- instance of this widget, so the poll rate limits below are one set of -- +-- counters however many instances the user has placed. -- -- -- -- Per background tick: elrsinfo:drain() to ingest the queue (which also -- -- refreshes crsf.hasTelemetry as it empties), then elrsinfo:update() to -- diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index ca500f3..3c8ed74 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -17,7 +17,7 @@ local function create(zone, options) _crsfSingleton = getCRSF() end if not _elrsInfoSingleton then - local getElrsInfo = loadScript("/SCRIPTS/ELRS/crsf_elrsinfo.lua") + local getElrsInfo = loadScript(table.concat({ "/WIDGETS/", name, "/elrsinfo.lua" })) ---@diagnostic disable-next-line: need-check-nil _elrsInfoSingleton = getElrsInfo(_crsfSingleton) end @@ -27,7 +27,7 @@ local function create(zone, options) -- shutdown rebuild, so the verdict the previous model's link gave would -- carry into this one. Drop it here and ask again. _elrsInfoSingleton:resetModelMatch() - local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + local loadable = loadScript(table.concat({ "/WIDGETS/", name, "/loadable.lua" })) ---@diagnostic disable-next-line: need-check-nil return loadable(zone, options, _crsfSingleton, _elrsInfoSingleton) end From 04e0ae1cfba256e2f5e57dca732d4c29fa986857 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:10:41 +0300 Subject: [PATCH 190/218] Give the CRSF simulator a GPS fix The Latitude and Longitude rows on the telemetry widget's full-screen page have never rendered anything but "--" under the simulator: no scenario defined a GPS sensor, so Telemetry.gps stayed nil and the one piece of state the widget deliberately caches across dropouts was the one piece nothing exercised. Added to the six scenarios that already carry the rest of the GPS group (Sats/GSpd/Alt), each with a distinct position so which scenario is running is readable off the screen. GPS stays out of sensorJitter, like the other non-moving sensors, so screenshots remain stable. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index f974f40..95c2832 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -1636,6 +1636,7 @@ local scenarioTelemetry = { Sats = 12, GSpd = 25.3, Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, }, -- A receiver with one RF path: it never writes uplink_RSSI_2, so 2RSS arrives -- as 0 dBm and the widget should report no diversity. @@ -1652,6 +1653,7 @@ local scenarioTelemetry = { Sats = 11, GSpd = 22.4, Alt = 120, + GPS = { lat = 54.6901, lon = 25.2712 }, }, armed = { TPWR = 250, @@ -1666,6 +1668,7 @@ local scenarioTelemetry = { Sats = 14, GSpd = 42.7, Alt = 85, + GPS = { lat = 54.7050, lon = 25.3100 }, }, -- Bench-realistic signal: a mismatch is caught next to the quad, and the -- active-antenna RSSI must clear the model-match poll's -70 dBm gate. @@ -1719,6 +1722,7 @@ local scenarioTelemetry = { Sats = 9, GSpd = 31.0, Alt = 210, + GPS = { lat = 54.6600, lon = 25.2400 }, }, reconnect = { -- Same as normal; only served when isRxAvailable() is true @@ -1745,6 +1749,7 @@ local scenarioTelemetry = { Sats = 12, GSpd = 25.3, Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, }, critical_error = { -- Same link as normal; only the ELRS status flags differ. @@ -1760,11 +1765,12 @@ local scenarioTelemetry = { Sats = 12, GSpd = 25.3, Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, }, } -- Jitter ranges for sensors that fluctuate in real life. --- Sensors not listed (TPWR, RFMD, ANT, FM, Sats) stay static. +-- Sensors not listed (TPWR, RFMD, ANT, FM, Sats, GPS) stay static. local sensorJitter = { ["1RSS"] = 3, -- +/- 3 dBm ["2RSS"] = 3, From cf9106b4e3cad9294008e833faed29858ca68b43 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:12:22 +0300 Subject: [PATCH 191/218] Split the RF mode tables out of the telemetry module The packet-rate names and their rated sensitivity floors are 140 lines of literals that sat between update() and the frame handlers, so neither the state machine nor the data could be read top to bottom. They also version on ExpressLRS's release clock, where everything around them versions on the link-etiquette clock. RFMOD/RFRSSI stop being public fields of deviceInfo. The +1 that turns the 0-based RFMD sensor into a 1-based Lua index now lives next to the literals that define the convention, behind RfModes.name() and RfModes.floor(), and the "RFMD" fallback with it. Not a memory saving -- both literal sets stay in select()'s constant pool. It stays eagerly loaded: a loadScript inside the DEVICE_INFO handler would put a file open and a compile in a frame handler at the moment the link comes up. --- src/WIDGETS/ELRSTelemetry/elrsinfo.lua | 164 ++------------------- src/WIDGETS/ELRSTelemetry/loadable.lua | 6 +- src/WIDGETS/ELRSTelemetry/rf_modes.lua | 195 +++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 155 deletions(-) create mode 100644 src/WIDGETS/ELRSTelemetry/rf_modes.lua diff --git a/src/WIDGETS/ELRSTelemetry/elrsinfo.lua b/src/WIDGETS/ELRSTelemetry/elrsinfo.lua index 9f0adb7..9c82e8e 100644 --- a/src/WIDGETS/ELRSTelemetry/elrsinfo.lua +++ b/src/WIDGETS/ELRSTelemetry/elrsinfo.lua @@ -3,11 +3,11 @@ -- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- -- returns the ElrsInfo singleton. -- -- -- --- Stateful companion to the CRSF singleton: DEVICE_INFO cache (module -- --- name, version-keyed RFMOD/RFRSSI lookup tables) and the model-match -- --- status, fed by drain(). Loaded once per Lua state and shared by every -- --- instance of this widget, so the poll rate limits below are one set of -- --- counters however many instances the user has placed. -- +-- Stateful companion to the CRSF singleton: DEVICE_INFO cache, the RF -- +-- mode tables it selects, and the model-match status, all fed by -- +-- drain(). Loaded once per Lua state and shared by every instance of -- +-- this widget, so the poll rate limits below are one set of counters -- +-- however many instances the user has placed. -- -- -- -- Per background tick: elrsinfo:drain() to ingest the queue (which also -- -- refreshes crsf.hasTelemetry as it empties), then elrsinfo:update() to -- @@ -16,14 +16,19 @@ local crsf = ... +local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() + local ElrsInfo = {} +-- The RF mode lookup, re-exported so consumers reach the tables through the +-- module that answered the device ping rather than loading them themselves. +ElrsInfo.rfModes = RfModes + -- ============================================================================ -- State -- ============================================================================ --- Device info cache (populated by the DEVICE_INFO handler): --- name, isElrs, RFMOD, RFRSSI +-- Device info cache (populated by the DEVICE_INFO handler): name, isElrs ElrsInfo.deviceInfo = {} -- Model match comes from the ELRS_STATUS answers updateModelMatch() asks for. @@ -47,11 +52,6 @@ ElrsInfo._statusAnswered = nil -- hasTelemetry as last seen by update(), to detect connection edges. ElrsInfo._wasConnected = false --- Effective major version of the RFMOD/RFRSSI tables currently built. The --- only version datum kept, purely so selectRfTables() can skip rebuilds. ----@type number? -ElrsInfo._rfMaj = nil - -- ============================================================================ -- Helpers -- ============================================================================ @@ -163,144 +163,6 @@ end -- Frame handlers -- ============================================================================ ---- Install the RFMOD/RFRSSI lookup tables for an ELRS major version. --- The highest known version at or below vMaj wins, so newer firmware --- degrades to the newest known tables instead of losing its rate names. --- Rebuilt only when the effective version changes (first answer, module --- swap across reconnects), never per frame. -local function selectRfTables(vMaj) - local effMaj - if vMaj >= 4 then - effMaj = 4 - elseif vMaj == 3 then - effMaj = 3 - end - if ElrsInfo._rfMaj == effMaj then - return - end - ElrsInfo._rfMaj = effMaj - - local info = ElrsInfo.deviceInfo - if effMaj == 4 then - -- selene: allow(mixed_table) - info.RFMOD = { - "25Hz", - "50Hz", - "100Hz", - "100HzFull", - "150Hz", - "200Hz", - "200HzFull", - "250Hz", - "333HzFull", - "500Hz", - "D50", - "K1000Full", - [21] = "25Hz", - [22] = "50Hz", - [23] = "100Hz", - [24] = "100HzFull", - [25] = "150Hz", - [26] = "200Hz", - [27] = "200HzFull", - [28] = "250Hz", - [29] = "333HzFull", - [30] = "500Hz", - [31] = "D250", - [32] = "D500", - [33] = "F500", - [34] = "F1000", - [35] = "DK250", - [36] = "DK500", - [37] = "K1000", - [101] = "X100Full", - [102] = "X150", - } - -- selene: allow(mixed_table) - info.RFRSSI = { - -123, - -120, - -117, - -112, - 0, - -112, - -111, - -111, - 0, - 0, - -112, - -101, - [21] = 0, - [22] = -115, - [23] = 0, - [24] = -112, - [25] = -112, - [26] = 0, - [27] = 0, - [28] = -108, - [29] = -105, - [30] = -105, - [31] = -104, - [32] = -104, - [33] = -104, - [34] = -104, - [35] = -103, - [36] = -103, - [37] = -103, - [101] = -112, - [102] = -112, - } - elseif effMaj == 3 then - info.RFMOD = { - "", - "25Hz", - "50Hz", - "100Hz", - "100HzFull", - "150Hz", - "200Hz", - "250Hz", - "333HzFull", - "500Hz", - "D250", - "D500", - "F500", - "F1000", - "D50", - "200HzFull", - "DK500", - "K1000", - "9K1000", - "K1000Full", - } - info.RFRSSI = { - 0, - -123, - -115, - -117, - -112, - -112, - -112, - -108, - -105, - -105, - -104, - -104, - -104, - -104, - -112, - -111, - -103, - -103, - 0, - -101, - } - else - info.RFMOD = nil - info.RFRSSI = nil - end -end - -- DEVICE_INFO handler: caches module name/identity and selects the RF tables local function onDeviceInfo(data) local info = crsf:decodeDeviceInfo(data) @@ -309,7 +171,7 @@ local function onDeviceInfo(data) end ElrsInfo.deviceInfo.name = info.name ElrsInfo.deviceInfo.isElrs = info.isElrs - selectRfTables(info.vMaj) + RfModes.select(info.vMaj) end -- ELRS_STATUS handler: latches the answer and updates modelMismatch diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 332c259..4bcc207 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -112,12 +112,11 @@ end --- Compute smoothed range percentage from RSSI. function Telemetry.getRangePct(tlm) - local mod = elrsinfo.deviceInfo local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 if rssi == nil then return 0 end - local minrssi = (mod.RFRSSI and tlm.rfmd and mod.RFRSSI[tlm.rfmd + 1]) or -128 + local minrssi = (tlm.rfmd and elrsinfo.rfModes.floor(tlm.rfmd)) or -128 if rssi > -50 then rssi = -50 end @@ -137,8 +136,7 @@ function Telemetry.getRfModeStr(rfmd) if not crsf.hasTelemetry or rfmd == nil then return "" end - local mod = elrsinfo.deviceInfo - return (mod.RFMOD and mod.RFMOD[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) + return elrsinfo.rfModes.name(rfmd) end --- Update GPS cache from telemetry. diff --git a/src/WIDGETS/ELRSTelemetry/rf_modes.lua b/src/WIDGETS/ELRSTelemetry/rf_modes.lua new file mode 100644 index 0000000..7ab0c36 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/rf_modes.lua @@ -0,0 +1,195 @@ +--------------------------------------------------------------------------- +-- ELRS RF Mode Tables -- +-- Loaded via loadScript() from ELRSTelemetry/elrsinfo.lua with no -- +-- arguments; returns the RfModes table. -- +-- -- +-- Pure data plus its selector: the packet-rate names ExpressLRS reports -- +-- through the RFMD sensor, and the receiver sensitivity floor each rate -- +-- is rated to. Both are keyed by the module's firmware major version and -- +-- change on ExpressLRS's release clock, which is why they live apart -- +-- from the polling policy that consumes them. -- +-- -- +-- RFMD is a 0-based sensor and these are 1-based Lua arrays, so the +1 -- +-- lives here, next to the literals that define the convention. Callers -- +-- pass the raw sensor value. -- +--------------------------------------------------------------------------- + +local RfModes = {} + +-- Names and floors for the currently selected major version, or nil while no +-- ELRS module has answered a device ping. +---@type table? +RfModes._names = nil +---@type table? +RfModes._floors = nil + +-- Effective major version of the tables currently built. The only version +-- datum kept, purely so select() can skip rebuilds. +---@type number? +RfModes._maj = nil + +-- ============================================================================ +-- Selection +-- ============================================================================ + +--- Install the lookup tables for an ELRS major version. +-- The highest known version at or below vMaj wins, so newer firmware +-- degrades to the newest known tables instead of losing its rate names. +-- Rebuilt only when the effective version changes (first answer, module +-- swap across reconnects), never per frame. +function RfModes.select(vMaj) + local effMaj + if vMaj >= 4 then + effMaj = 4 + elseif vMaj == 3 then + effMaj = 3 + end + if RfModes._maj == effMaj then + return + end + RfModes._maj = effMaj + + if effMaj == 4 then + -- selene: allow(mixed_table) + RfModes._names = { + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "200HzFull", + "250Hz", + "333HzFull", + "500Hz", + "D50", + "K1000Full", + [21] = "25Hz", + [22] = "50Hz", + [23] = "100Hz", + [24] = "100HzFull", + [25] = "150Hz", + [26] = "200Hz", + [27] = "200HzFull", + [28] = "250Hz", + [29] = "333HzFull", + [30] = "500Hz", + [31] = "D250", + [32] = "D500", + [33] = "F500", + [34] = "F1000", + [35] = "DK250", + [36] = "DK500", + [37] = "K1000", + [101] = "X100Full", + [102] = "X150", + } + -- selene: allow(mixed_table) + RfModes._floors = { + -123, + -120, + -117, + -112, + 0, + -112, + -111, + -111, + 0, + 0, + -112, + -101, + [21] = 0, + [22] = -115, + [23] = 0, + [24] = -112, + [25] = -112, + [26] = 0, + [27] = 0, + [28] = -108, + [29] = -105, + [30] = -105, + [31] = -104, + [32] = -104, + [33] = -104, + [34] = -104, + [35] = -103, + [36] = -103, + [37] = -103, + [101] = -112, + [102] = -112, + } + elseif effMaj == 3 then + RfModes._names = { + "", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "250Hz", + "333HzFull", + "500Hz", + "D250", + "D500", + "F500", + "F1000", + "D50", + "200HzFull", + "DK500", + "K1000", + "9K1000", + "K1000Full", + } + RfModes._floors = { + 0, + -123, + -115, + -117, + -112, + -112, + -112, + -108, + -105, + -105, + -104, + -104, + -104, + -104, + -112, + -111, + -103, + -103, + 0, + -101, + } + else + RfModes._names = nil + RfModes._floors = nil + end +end + +-- ============================================================================ +-- Lookup +-- ============================================================================ + +--- Packet-rate name for an RFMD sensor value. +-- Falls back to "RFMD" for a rate this firmware version's table does not +-- name, and for every rate while no module has answered yet. +function RfModes.name(rfmd) + local names = RfModes._names + return (names and names[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) +end + +--- Rated receiver sensitivity in dBm for an RFMD sensor value, or nil when +--- the rate is unknown. Rates the tables carry as 0 are unrated, not 0 dBm. +function RfModes.floor(rfmd) + local floors = RfModes._floors + return floors and floors[rfmd + 1] +end + +-- ============================================================================ +-- Return module +-- ============================================================================ + +return RfModes From c821c4816eaddd754f9a3454ec377cc61a1d5ada Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:18:42 +0300 Subject: [PATCH 192/218] Give the telemetry widget's state and read model an owner each loadable.lua was four layers in one file and the state it drove was split across the library boundary, which had duplicated it: the same tick sampled ANT/1RSS/2RSS twice, picked the active antenna through two functions that did the same thing, and tracked the connection edge in two latches. Three restatements of the same status ladder had drifted apart, and each of the five screen files carried its own byte-identical detailColor() and heroTextLq() -- so a colour rule could be changed on four screens out of five. telemetry.lua now owns everything that describes the link: the drain, the device cache, the model-match policy, the sensor snapshot and every value derived from it. It is one owner because all of it describes the radio, not a widget instance -- two widgets on one screen were two independently sampled copies that could disagree within a frame. ui/display.lua is the read model the layouts consume, so the view no longer reaches past it: crsf is now a vararg of exactly one file in the widget, which makes the one-drainer rule something grep can check. Sharing the snapshot needs two guarantees, both in the code and both stated in the headers. update() samples at most once per tick, or the range smoother would step once per instance per frame and settle N times faster on a screen with N widgets; drain() stays per instance and ungated, because each instance owns a firmware pop queue only it can empty. It also needs a reset contract, which replaces one that used to be half explicit and half accidental. The singleton outlives a model change where the old per-instance table did not, so resetModel() clears what belongs to the model -- the GPS position above all, which nothing ever cleared and which reads as a live fix. It is keyed on the model name rather than on create() alone, because create() also runs when a widget is placed, and adding a second telemetry widget must not throw away the cell count the first one locked on to. The full-screen page now reads the snapshot instead of sampling five sensors at paint time, so every row on it is from the same instant. --- README.md | 11 +- docs/development.md | 2 +- src/WIDGETS/ELRSTelemetry/elrsinfo.lua | 208 -------- src/WIDGETS/ELRSTelemetry/loadable.lua | 565 +++++----------------- src/WIDGETS/ELRSTelemetry/main.lua | 31 +- src/WIDGETS/ELRSTelemetry/rf_modes.lua | 2 +- src/WIDGETS/ELRSTelemetry/telemetry.lua | 445 +++++++++++++++++ src/WIDGETS/ELRSTelemetry/ui/display.lua | 257 ++++++++++ src/WIDGETS/ELRSTelemetry/ui/hd.lua | 73 +-- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 69 +-- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 71 +-- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 71 +-- src/WIDGETS/ELRSTelemetry/ui/small.lua | 69 +-- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 32 +- 14 files changed, 985 insertions(+), 921 deletions(-) delete mode 100644 src/WIDGETS/ELRSTelemetry/elrsinfo.lua create mode 100644 src/WIDGETS/ELRSTelemetry/telemetry.lua create mode 100644 src/WIDGETS/ELRSTelemetry/ui/display.lua diff --git a/README.md b/README.md index 8cf4143..3fe2020 100644 --- a/README.md +++ b/README.md @@ -49,11 +49,14 @@ SCRIPTS/ lcd.lua -- black & white LCD UI WIDGETS/ ELRSTelemetry/ - main.lua - elrsinfo.lua -- TX module info state - loadable.lua + main.lua -- entry point + telemetry.lua -- link state: drain, device info, model match, sensor snapshot + rf_modes.lua -- RF mode names and RSSI floors, by firmware version + loadable.lua -- per-instance wiring and lifecycle ui/ - ... + display.lua -- read model shared by every layout below + topbar.lua -- top-bar layout + sd.lua sd_tall.lua hd.lua portrait.lua small.lua ELRSVTXAdmin/ main.lua loadable.lua diff --git a/docs/development.md b/docs/development.md index fb9897d..6a1eaa4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -76,7 +76,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and the firmware delivers every widget instance its own copy of each incoming frame, so **each script instance has exactly one draining consumer**: the config tool and the VTX Admin widget drain through -`session:drain()`, the telemetry widget through `elrsinfo:drain()`, and the bind tool through +`session:drain()`, the telemetry widget through `Telemetry.drain()`, and the bind tool through `crsf.drain(App, App.onFrame)`. A future widget needing two consumers must pop once and route the frames itself. `reassemble()` callers pass the field id they are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`, diff --git a/src/WIDGETS/ELRSTelemetry/elrsinfo.lua b/src/WIDGETS/ELRSTelemetry/elrsinfo.lua deleted file mode 100644 index 9c82e8e..0000000 --- a/src/WIDGETS/ELRSTelemetry/elrsinfo.lua +++ /dev/null @@ -1,208 +0,0 @@ ---------------------------------------------------------------------------- --- ELRS TX Module Info -- --- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- --- returns the ElrsInfo singleton. -- --- -- --- Stateful companion to the CRSF singleton: DEVICE_INFO cache, the RF -- --- mode tables it selects, and the model-match status, all fed by -- --- drain(). Loaded once per Lua state and shared by every instance of -- --- this widget, so the poll rate limits below are one set of counters -- --- however many instances the user has placed. -- --- -- --- Per background tick: elrsinfo:drain() to ingest the queue (which also -- --- refreshes crsf.hasTelemetry as it empties), then elrsinfo:update() to -- --- pump the outgoing requests. -- ---------------------------------------------------------------------------- - -local crsf = ... - -local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() - -local ElrsInfo = {} - --- The RF mode lookup, re-exported so consumers reach the tables through the --- module that answered the device ping rather than loading them themselves. -ElrsInfo.rfModes = RfModes - --- ============================================================================ --- State --- ============================================================================ - --- Device info cache (populated by the DEVICE_INFO handler): name, isElrs -ElrsInfo.deviceInfo = {} - --- Model match comes from the ELRS_STATUS answers updateModelMatch() asks for. --- The module recomputes the flag as it answers and never sends one unasked, so --- the value is exactly as old as the last answer. ----@type boolean? -ElrsInfo.modelMismatch = nil - --- Device info polling -ElrsInfo._lastDevPoll = 0 - --- ELRS status polling -ElrsInfo._lastStatusPoll = 0 - --- Set once an ELRS_STATUS answer has arrived, so a match is asked about once --- rather than polled. Cleared with the rest of the per-connection state on any --- hasTelemetry edge in update(), and by resetModelMatch() on widget create. ----@type boolean? -ElrsInfo._statusAnswered = nil - --- hasTelemetry as last seen by update(), to detect connection edges. -ElrsInfo._wasConnected = false - --- ============================================================================ --- Helpers --- ============================================================================ - ---- RSSI of the antenna currently in use, or nil while unknown. -local function getActiveRssi() - local ant = crsf.getSensorValue("ANT") - return (ant == 1) and crsf.getSensorValue("2RSS") or crsf.getSensorValue("1RSS") -end - --- ============================================================================ --- Outgoing requests --- ============================================================================ - ---- Ask the TX module for its DEVICE_INFO if it is not cached yet. --- Addressed to the module itself, so it stays off the air. EdgeTX pings once --- at module init, but that answer lands before any widget's Lua queue exists --- (queues are created lazily on the first crossfireTelemetryPop), so widgets --- must ask themselves. Rate-limited to at most once per second, permanently --- quiet once answered. -local function requestDeviceInfo() - if ElrsInfo.deviceInfo.name then - return - end - local now = getTime() - if now - ElrsInfo._lastDevPoll < 100 then - return - end - ElrsInfo._lastDevPoll = now - crsf:pingDevices(crsf.CONST.ADDRESS_TX) -end - --- Weakest link a status request will be spent on (rule 4 below). -local MODEL_MATCH_MIN_RSSI = -70 - ---- Whether a status request is worth the frame it costs, right now. --- requestElrsStatus() is answered locally by the module, but the push still --- replaces one RC-channels frame on the handset->module UART, so every rule --- below weighs one answer against one frame. All of them must hold. -local function canRequestStatus(now) - -- 1. Connected. The module reports no model-match verdict without a link, - -- and the answer would carry nothing to display. - if not crsf.hasTelemetry then - return false - end - -- 2. ExpressLRS. The fieldId=0 request is an ELRS convention; a TBS module - -- answers device pings but never this, and would be asked forever. - if not ElrsInfo.deviceInfo.isElrs then - return false - end - -- 3. No answer yet, or the last one was a mismatch. One answer settles a - -- match, but the module recomputes the flag as it answers and announces - -- nothing on its own, while every way out of a mismatch -- receiver - -- number, model select, the tool's Model Match switch, a rebind -- is - -- applied over a link the handset never sees drop. Latching there would - -- pin the warning on screen for the rest of the session. - if ElrsInfo._statusAnswered and not ElrsInfo.modelMismatch then - return false - end - -- 4. Strong link. A mismatch is caught next to the quad; out at range every - -- RC-channels frame is worth more than the answer is. - local rssi = getActiveRssi() - if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then - return false - end - -- 5. At most once per second, which is as fresh as an answer gets: the - -- module latches its own packet counters on a 1 s watchdog. - return now - ElrsInfo._lastStatusPoll >= 100 -end - ---- Keep modelMismatch current, one request per second at the very most. -local function updateModelMatch() - local now = getTime() - if not canRequestStatus(now) then - return - end - ElrsInfo._lastStatusPoll = now - crsf:requestElrsStatus() -end - ---- Forget the model-match verdict, so the next tick asks for a fresh one. --- Called from widget create(), which runs again for every instance of every --- model; this singleton outlives them, so without it the previous model's --- verdict carries over. deviceInfo stays -- the module did not change. -function ElrsInfo:resetModelMatch() - self.modelMismatch = nil - self._statusAnswered = nil -end - ---- Per-tick pump; call right after drain() so the tick's frames are --- ingested and hasTelemetry is current. Any hasTelemetry edge wipes the --- per-connection state before updateModelMatch() runs: a stale --- modelMismatch can neither survive a disconnect nor suppress the next --- connection's status poll via a late-arriving answer. hasTelemetry only --- refreshes as a drain empties the queue, so a dying connection's --- ELRS_STATUS always lands before the edge is observed here. -function ElrsInfo:update() - local connected = crsf.hasTelemetry - if connected ~= self._wasConnected then - self._wasConnected = connected - self.modelMismatch = nil - self._statusAnswered = nil - end - requestDeviceInfo() - updateModelMatch() -end - --- ============================================================================ --- Frame handlers --- ============================================================================ - --- DEVICE_INFO handler: caches module name/identity and selects the RF tables -local function onDeviceInfo(data) - local info = crsf:decodeDeviceInfo(data) - if info == nil or info.id ~= crsf.CONST.ADDRESS_TX then - return -- short frame (the ping retries) or not the TX module - end - ElrsInfo.deviceInfo.name = info.name - ElrsInfo.deviceInfo.isElrs = info.isElrs - RfModes.select(info.vMaj) -end - --- ELRS_STATUS handler: latches the answer and updates modelMismatch -local function onElrsStatus(data) - local status = crsf:decodeElrsStatus(data) - if status == nil or status.id ~= crsf.CONST.ADDRESS_TX then - return - end - ElrsInfo._statusAnswered = true - ElrsInfo.modelMismatch = status.modelMismatch -end - ---- Route one frame into the info cache. Frames of other types are --- dropped: this singleton is its widget's only queue consumer (pop is --- destructive per instance), and the firmware delivers every instance its --- own copy of each frame. -function ElrsInfo:_onFrame(command, data) - if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then - onDeviceInfo(data) - elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then - onElrsStatus(data) - end -end - ---- Drain the calling script instance's pop queue into the info cache. -function ElrsInfo:drain() - crsf.drain(self, self._onFrame) -end - --- ============================================================================ --- Return singleton --- ============================================================================ - -return ElrsInfo diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 4bcc207..bc4edad 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -1,360 +1,24 @@ --------------------------------------------------------------------------- --- ELRS Telemetry Widget - Core Logic -- --- Loaded via loadScript() from ELRSTelemetry/main.lua -- +-- ELRS Telemetry Widget - Wiring -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with -- +-- (zone, options, Telemetry); returns the widget instance table. -- -- -- --- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- --- singleton for transport and crsf_elrsinfo.lua for device info/match. -- +-- One of these exists per placed widget. It owns no state of its own: -- +-- everything it shows lives in the Telemetry singleton, which outlives -- +-- it. All this file does is pick the layout for the screen, wire the -- +-- components together, and drive them from the widget callbacks. -- -- -- --- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- +-- The minimized layout is per screen size; the full-screen page is -- +-- shared, because lvgl.page() handles the responsive part itself. -- --------------------------------------------------------------------------- -local zone, options, crsf, elrsinfo = ... - --- Forward declarations for modules -local Telemetry - --- ============================================================================ --- Telemetry Module: cell counting, state, power mapping --- ============================================================================ - -Telemetry = { - -- Smoothed range percentage - smoothRng = nil, - - -- Range percentage, recomputed once per tick by Telemetry.update() - rangePct = 0, - - -- Cell count detection state - cellCnt = nil, - ---@type number|nil - cellCntCnt = nil, - cellLastV = nil, - - -- Connection state, used to detect the falling edge on disconnect - wasConnected = false, - - -- Cached GPS position (persists across disconnects) - ---@type {lat: number, lon: number}|nil - gps = nil, - - -- Shared link snapshot, refilled once per tick by Telemetry.update(). - -- The table identity never changes, so consumers may cache a reference. - link = {}, - - -- Power level mapping table - POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 }, -} - ---- Map a power value in mW to a 0-based index. -function Telemetry.pwrToIdx(powval) - for k, v in ipairs(Telemetry.POWERS) do - if powval == v then - return k - 1 - end - end - return 7 -end - ---- Cell count detection heuristic (same logic as original). -function Telemetry.checkCellCount(v) - -- once the cellCnt is the same X times in a row, stop updating - if (Telemetry.cellCntCnt or 0) > 5 then - return - end - - -- try to lock on to the cell count, so as the voltage sags we don't change S - local cellCnt = math.floor(v / 4.35) + 1 - -- Prevent lock on when no voltage is present - if (v / cellCnt) < 3.0 then - return - end - - if Telemetry.cellCnt ~= cellCnt then - Telemetry.cellCnt = cellCnt - Telemetry.cellCntCnt = 0 - else - -- The value has to change to count as an update - if Telemetry.cellLastV == v then - return - end - Telemetry.cellLastV = v - Telemetry.cellCntCnt = Telemetry.cellCntCnt + 1 - end -end - ---- Check if a CRSF/ELRS module is available. -function Telemetry.hasModule() - return crsf.hasCrsfModule() -end - ---- True when a connected link is reporting a model mismatch. ---- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means ---- anything while connected: ungated, a stale flag paints a label RED under text reading "--". -function Telemetry.isMismatch() - return crsf.hasTelemetry and elrsinfo.modelMismatch -end - ---- Short status text when not operational or warning active. ---- Returns nil when connected with no warnings. ---- Used by both full-screen and minimized UIs. -function Telemetry.statusText() - if not crsf.hasCrsfModule() then - return "No CRSF module" - end - if not crsf.hasTelemetry then - return "No telemetry" - end - if elrsinfo.modelMismatch then - return "Model Mismatch" - end - return nil -end - ---- Compute smoothed range percentage from RSSI. -function Telemetry.getRangePct(tlm) - local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 - if rssi == nil then - return 0 - end - local minrssi = (tlm.rfmd and elrsinfo.rfModes.floor(tlm.rfmd)) or -128 - if rssi > -50 then - rssi = -50 - end - local pct = math.floor(100 * (rssi + 50) / (minrssi + 50) + 0.5) - local smooth = Telemetry.smoothRng or pct - if pct > smooth then - pct = smooth + ((pct > smooth + 8) and 4 or 1) - elseif pct < smooth then - pct = smooth - ((pct < smooth - 8) and 4 or 1) - end - Telemetry.smoothRng = pct - return pct -end - ---- Get RF mode string from device info. -function Telemetry.getRfModeStr(rfmd) - if not crsf.hasTelemetry or rfmd == nil then - return "" - end - return elrsinfo.rfModes.name(rfmd) -end - ---- Update GPS cache from telemetry. -function Telemetry.updateGps() - local gps = crsf.getSensorValue("GPS") - if gps and gps ~= 0 then - Telemetry.gps = gps - end -end - ---- Whether the RX reports a second antenna. ---- An RX only writes uplink_RSSI_2 when it has two RF paths — a dual-radio RX ---- fills it every packet, a switched-antenna RX once it first selects antenna 2. ---- A single-antenna RX never touches it, so it arrives as 0 dBm, impossible for ---- a real signal. Same test ExpressLRS uses on the TX module's own screens. -function Telemetry.hasDiversity() - local rssi2 = Telemetry.link.rssi2 - return rssi2 ~= nil and rssi2 ~= 0 -end - ---- Pick the active antenna's RSSI value from a link snapshot. -function Telemetry.getRssi(tlm) - if not tlm then - return nil - end - return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 -end - ---- Clear per-connection state. ---- The original discarded its whole ctx table on RX disconnect, so cell count and ---- range smoothing re-detect on the next battery instead of latching forever. ---- gps is deliberately kept — the port caches last-known position across dropouts. -function Telemetry.resetConnection() - Telemetry.smoothRng = nil - Telemetry.rangePct = 0 - Telemetry.cellCnt = nil - Telemetry.cellCntCnt = nil - Telemetry.cellLastV = nil -end - ---- Refill the shared link snapshot and recompute all derived state. ---- Called once per tick from wgt.background(). EdgeTX runs wgt.refresh() immediately ---- before it evaluates every LVGL text/color callback, so labels always read values ---- sampled in their own frame and every field sees the same snapshot. -function Telemetry.update() - local link = Telemetry.link - link.tpwr = crsf.getSensorValue("TPWR") - link.rfmd = crsf.getSensorValue("RFMD") - link.rssi1 = crsf.getSensorValue("1RSS") - link.rssi2 = crsf.getSensorValue("2RSS") - link.rqly = crsf.getSensorValue("RQly") - link.ant = crsf.getSensorValue("ANT") - link.vbat = crsf.getSensorValue("RxBt") - - local connected = crsf.hasTelemetry - if Telemetry.wasConnected and not connected then - Telemetry.resetConnection() - end - Telemetry.wasConnected = connected - if not connected then - return - end - - Telemetry.updateGps() - Telemetry.rangePct = Telemetry.getRangePct(link) - if link.vbat then - Telemetry.checkCellCount(link.vbat) - end -end - ---- Hero label colour: red only while a connected link reports a model mismatch. -function Telemetry.heroColor() - if Telemetry.isMismatch() then - return RED - end - return COLOR_THEME_PRIMARY1 -end - ---- Hero label font for one tier of a screen's WidgetUI.fonts table. ---- Status text ("No CRSF module") is far longer than "LQ 100%", so tiers that would ---- overflow declare a smaller heroStatus and drop to it while a status shows. ---- Tiers without one get the constant back, so no callback runs per frame. -function Telemetry.heroFont(tier) - if not tier.heroStatus then - return tier.hero - end - return function() - if Telemetry.statusText() then - return tier.heroStatus - end - return tier.hero - end -end - ---- Map range percentage to a warning color. -function Telemetry.rangeColor(pct) - if pct > 90 then - return RED - end - if pct > 70 then - return ORANGE - end - return COLOR_THEME_SECONDARY1 -end - ---- Range percentage + RSSI text (e.g. "Range 69% -90dBm"). -function Telemetry.signalText() - if not crsf.hasTelemetry then - return "" - end - local parts = { table.concat({ "Range ", tostring(Telemetry.rangePct), "%" }) } - local rssi = Telemetry.getRssi(Telemetry.link) - if rssi then - parts[#parts + 1] = table.concat({ tostring(rssi), "dBm" }) - end - return table.concat(parts, " ") -end - ---- RF mode text (e.g. "250Hz"). Narrow zones use this without the power suffix. -function Telemetry.rfModeText() - return Telemetry.getRfModeStr(Telemetry.link.rfmd) -end - ---- RF mode + TX power text (e.g. "250Hz 50mW"). -function Telemetry.rfDetailText() - local parts = { Telemetry.rfModeText() } - local tpwr = Telemetry.link.tpwr - if crsf.hasTelemetry and tpwr then - parts[#parts + 1] = table.concat({ tostring(tpwr), "mW" }) - end - return table.concat(parts, " ") -end - ---- Battery text for minimized layouts (e.g. "Bat 4S 3.80V"). -function Telemetry.batteryText() - local vbat = Telemetry.link.vbat - if vbat == nil or vbat <= 0 then - return "" - end - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) -end - ---- Battery text for the full-screen row (e.g. "4S 3.80V (15.20V)"). -function Telemetry.batteryTextVerbose() - local vbat = Telemetry.link.vbat - if vbat == nil or vbat <= 0 then - return "--" - end - local cells = Telemetry.cellCnt - if cells then - return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) - end - return string.format("%.2fV", vbat) -end +local zone, options, Telemetry = ... -- ============================================================================ --- WidgetLayout: minimized zone container builders +-- Display components -- ============================================================================ -local WidgetLayout = {} - -function WidgetLayout.column(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = 0, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end - -function WidgetLayout.row(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end +local Display, WidgetLayout = loadScript("/WIDGETS/ELRSTelemetry/ui/display.lua")(Telemetry) -- ============================================================================ -- Screen detection and UI loading @@ -385,8 +49,7 @@ end local screenId = getScreenId() local uiPath = table.concat({ "/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ - crsf = crsf, - Telemetry = Telemetry, + Display = Display, bgOpacity = bgOpacity, WidgetLayout = WidgetLayout, }) @@ -398,6 +61,19 @@ local WidgetUI = loadScript(uiPath)({ -- Portrait screens get a narrower label column to leave more room for values. local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 +--- Wrap a value formatter so a row reads "--" while the link is down. +--- Only the Link Status and Power rows take this: the flight controller and +--- GPS rows have their own fallbacks, and latitude/longitude deliberately keep +--- showing the last known position after the link drops. +local function whenConnected(fn) + return function() + if not Telemetry.isConnected() then + return "--" + end + return fn() + end +end + local function createDisplayRow(container, label, valueFn, colorFn) container:rectangle({ w = lvgl.PERCENT_SIZE + 100, @@ -449,18 +125,7 @@ local function buildFullScreen() local pg = lvgl.page({ title = "ExpressLRS", - subtitle = function() - if not Telemetry.hasModule() then - return "No CRSF module" - end - if not crsf.hasTelemetry then - return "No telemetry" - end - if elrsinfo.modelMismatch then - return "Model Mismatch" - end - return "Telemetry" - end, + subtitle = Display.pageSubtitle, back = function() lvgl.exitFullScreen() end, @@ -500,107 +165,109 @@ local function buildFullScreen() font = BOLD, color = RED, text = "Model Mismatch — RC commands not sent", - visible = Telemetry.isMismatch, + visible = Display.isMismatch, }, }) -- Link Status section createSectionHeader(fields, "Link Status") - createDisplayRow(fields, "RF Mode", Telemetry.rfModeText) - - createDisplayRow(fields, "Link Quality", function() - if not crsf.hasTelemetry then - return "--" - end - return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) - end) + createDisplayRow(fields, "RF Mode", Display.rfModeText) - createDisplayRow(fields, "RSSI 1", function() - if not crsf.hasTelemetry then - return "--" - end - local rssi1 = Telemetry.link.rssi1 - if rssi1 == nil then - return "--" - end - return table.concat({ tostring(rssi1), " dBm" }) - end) + createDisplayRow(fields, "Link Quality", Display.lqValueText) - createDisplayRow(fields, "RSSI 2", function() - if not crsf.hasTelemetry then - return "--" - end - local rssi2 = Telemetry.link.rssi2 - if rssi2 == nil then - return "--" - end - return table.concat({ tostring(rssi2), " dBm" }) - end, function() - if not Telemetry.hasDiversity() then - return COLOR_THEME_DISABLED + createDisplayRow( + fields, + "RSSI 1", + whenConnected(function() + local rssi1 = Telemetry.link.rssi1 + if rssi1 == nil then + return "--" + end + return table.concat({ tostring(rssi1), " dBm" }) + end) + ) + + createDisplayRow( + fields, + "RSSI 2", + whenConnected(function() + local rssi2 = Telemetry.link.rssi2 + if rssi2 == nil then + return "--" + end + return table.concat({ tostring(rssi2), " dBm" }) + end), + function() + if not Telemetry.hasDiversity() then + return COLOR_THEME_DISABLED + end + return COLOR_THEME_SECONDARY1 end - return COLOR_THEME_SECONDARY1 - end) - - createDisplayRow(fields, "Active Antenna", function() - if not crsf.hasTelemetry then + ) + + createDisplayRow( + fields, + "Active Antenna", + whenConnected(function() + if not Telemetry.hasDiversity() then + return "N/A" + end + -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX + -- module's own screen. The "Ant " prefix keeps this row from reading as that + -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above. + if Telemetry.link.ant == 0 then + return "Ant 1" + end + if Telemetry.link.ant == 1 then + return "Ant 2" + end return "--" - end - if not Telemetry.hasDiversity() then - return "N/A" - end - -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX - -- module's own screen. The "Ant " prefix keeps this row from reading as that - -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above. - if Telemetry.link.ant == 0 then - return "Ant 1" - end - if Telemetry.link.ant == 1 then - return "Ant 2" - end - return "--" - end) + end) + ) - createDisplayRow(fields, "Range", function() - if not crsf.hasTelemetry then - return "--" - end - return table.concat({ tostring(Telemetry.rangePct), "%" }) - end) + createDisplayRow( + fields, + "Range", + whenConnected(function() + return table.concat({ tostring(Telemetry.rangePct), "%" }) + end) + ) -- Power section createSectionHeader(fields, "Power") - createDisplayRow(fields, "TX Power", function() - if not crsf.hasTelemetry then - return "--" - end - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return "--" - end - return table.concat({ tostring(tpwr), " mW" }) - end) - - createDisplayRow(fields, "Power Index", function() - if not crsf.hasTelemetry then - return "--" - end - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return "--" - end - return tostring(Telemetry.pwrToIdx(tpwr)) - end) + createDisplayRow( + fields, + "TX Power", + whenConnected(function() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) + end) + ) + + createDisplayRow( + fields, + "Power Index", + whenConnected(function() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return tostring(Telemetry.powerIndex(tpwr)) + end) + ) -- Flight Controller section createSectionHeader(fields, "Flight Controller") - createDisplayRow(fields, "Battery", Telemetry.batteryTextVerbose) + createDisplayRow(fields, "Battery", Display.batteryTextVerbose) createDisplayRow(fields, "Current", function() - local curr = crsf.getSensorValue("Curr") + local curr = Telemetry.link.curr if curr == nil or curr <= 0 then return "--" end @@ -608,7 +275,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Flight Mode", function() - local fm = crsf.getSensorValue("FM") + local fm = Telemetry.link.fm if fm == nil or fm == 0 then return "--" end @@ -619,7 +286,7 @@ local function buildFullScreen() createSectionHeader(fields, "GPS") createDisplayRow(fields, "Satellites", function() - local sats = crsf.getSensorValue("Sats") + local sats = Telemetry.link.sats if sats == nil then return "--" end @@ -627,7 +294,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Speed", function() - local gspd = crsf.getSensorValue("GSpd") + local gspd = Telemetry.link.gspd if gspd == nil then return "--" end @@ -635,7 +302,7 @@ local function buildFullScreen() end) createDisplayRow(fields, "Altitude", function() - local alt = crsf.getSensorValue("Alt") + local alt = Telemetry.link.alt if alt == nil then return "--" end @@ -666,9 +333,11 @@ local wgt = { options = options, } +-- drain() is per instance and ungated: this instance owns a pop queue in the +-- firmware that only it can empty. update() is shared and samples at most once +-- per tick, so the instances after the first fall through it. function wgt.background() - elrsinfo:drain() - elrsinfo:update() + Telemetry.drain() Telemetry.update() end diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index 3c8ed74..6f310c6 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -4,7 +4,7 @@ -- Battery, Current, GPS, and Flight Mode. -- -- -- -- Uses the loadable.lua pattern to minimize memory when not in use. -- --- Requires /SCRIPTS/ELRS on the SD card for shared CRSF protocol. -- +-- Requires /SCRIPTS/ELRS on the SD card for shared CRSF transport. -- --------------------------------------------------------------------------- local name = "ELRSTelemetry" @@ -16,20 +16,31 @@ local function create(zone, options) ---@diagnostic disable-next-line: need-check-nil _crsfSingleton = getCRSF() end - if not _elrsInfoSingleton then - local getElrsInfo = loadScript(table.concat({ "/WIDGETS/", name, "/elrsinfo.lua" })) + if not _elrsTelemetrySingleton then + local getTelemetry = loadScript(table.concat({ "/WIDGETS/", name, "/telemetry.lua" })) ---@diagnostic disable-next-line: need-check-nil - _elrsInfoSingleton = getElrsInfo(_crsfSingleton) + _elrsTelemetrySingleton = getTelemetry(_crsfSingleton) end -- Every model has its own widgets, so create() runs again for each of them - -- on a model change -- but the singletons above do not go with them. They - -- are globals in the widget Lua state, which only boot and resume from - -- shutdown rebuild, so the verdict the previous model's link gave would - -- carry into this one. Drop it here and ask again. - _elrsInfoSingleton:resetModelMatch() + -- on a model change -- but the singleton above does not go with them. It is + -- a global in the widget Lua state, which only boot and resume from shutdown + -- rebuild, so the previous model's aircraft would still be on screen: its + -- last GPS position above all, indistinguishable from a live fix. + -- + -- Keyed on the model, not on create() alone, because create() also runs when + -- a widget is placed: adding a second telemetry widget must not throw away + -- the cell count and position the first one has locked on to. Two models + -- with the same name share a verdict, which is as far as this needs to go. + local modelId = model.getInfo().name + if _elrsTelemetrySingleton.modelId ~= modelId then + _elrsTelemetrySingleton.modelId = modelId + _elrsTelemetrySingleton.resetModel() + end + -- Cheap, idempotent and self-repairing, so every create() may ask again. + _elrsTelemetrySingleton.resetModelMatch() local loadable = loadScript(table.concat({ "/WIDGETS/", name, "/loadable.lua" })) ---@diagnostic disable-next-line: need-check-nil - return loadable(zone, options, _crsfSingleton, _elrsInfoSingleton) + return loadable(zone, options, _elrsTelemetrySingleton) end local function refresh(widget, event, touchState) diff --git a/src/WIDGETS/ELRSTelemetry/rf_modes.lua b/src/WIDGETS/ELRSTelemetry/rf_modes.lua index 7ab0c36..78eb61b 100644 --- a/src/WIDGETS/ELRSTelemetry/rf_modes.lua +++ b/src/WIDGETS/ELRSTelemetry/rf_modes.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- ELRS RF Mode Tables -- --- Loaded via loadScript() from ELRSTelemetry/elrsinfo.lua with no -- +-- Loaded via loadScript() from ELRSTelemetry/telemetry.lua with no -- -- arguments; returns the RfModes table. -- -- -- -- Pure data plus its selector: the packet-rate names ExpressLRS reports -- diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua new file mode 100644 index 0000000..85820b0 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -0,0 +1,445 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry State -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- +-- returns the Telemetry singleton. -- +-- -- +-- Everything the widget knows about the link, in one owner: the frame -- +-- drain, the DEVICE_INFO cache and the RF mode tables it selects, the -- +-- model-match status and the rules for when it may be asked for, the -- +-- per-tick sensor snapshot, and every value derived from it. -- +-- -- +-- All of that describes the radio's link, not a widget instance, so it -- +-- is loaded once per Lua state and shared by every instance. Three -- +-- things follow, and none of them are optional: -- +-- -- +-- 1. drain() runs per instance, every frame, ungated. Each widget -- +-- instance owns a private pop queue in the firmware, so skipping it -- +-- for the instances that did not sample this tick would leave those -- +-- queues to fill and overflow. -- +-- 2. update() samples once per tick however many instances call it. -- +-- Without that, the range smoother would step once per instance per -- +-- frame and settle N times faster on a screen with N widgets. -- +-- 3. The frame handlers stay pure assignment. Every instance is -- +-- delivered its own copy of each frame, so on a radio this singleton -- +-- decodes the same frame N times; a counter here would be N times -- +-- wrong on hardware and right in the simulator. -- +-- -- +-- Per background tick: Telemetry.drain() to ingest the queue (which -- +-- also refreshes crsf.hasTelemetry as it empties), then -- +-- Telemetry.update() to sample and pump the outgoing requests. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() + +local Telemetry = {} + +-- ============================================================================ +-- State +-- ============================================================================ + +-- Sensor snapshot, refilled once per tick by update(). The table identity +-- never changes, so consumers may cache a reference. +Telemetry.link = {} + +-- Smoothed range percentage, recomputed once per tick from the snapshot. +Telemetry.rangePct = 0 + +-- Detected battery cell count, or nil while no plausible pack voltage has +-- been seen. Locks on once the same count survives a few readings. +---@type number? +Telemetry.cellCnt = nil + +-- Last known GPS position. Deliberately survives a dropout -- it is what you +-- read when looking for a model that stopped answering -- so only a model +-- change clears it. +---@type {lat: number, lon: number}|nil +Telemetry.gps = nil + +-- Model match comes from the ELRS_STATUS answers updateModelMatch() asks for. +-- The module recomputes the flag as it answers and never sends one unasked, so +-- the value is exactly as old as the last answer. +---@type boolean? +Telemetry.modelMismatch = nil + +-- Model this state belongs to, so create() can tell a model change from a +-- second widget being placed on the model already loaded. +---@type string? +Telemetry.modelId = nil + +-- Cell count detection working state. +---@type number? +Telemetry._cellCntCnt = nil +Telemetry._cellLastV = nil + +-- Unsmoothed range feed for rangePct. +---@type number? +Telemetry._smoothRng = nil + +-- Device info cache (populated by the DEVICE_INFO handler): name, isElrs. +-- Neither is displayed: name is the "already answered" latch that stops the +-- ping, isElrs is the gate on rule 2 below. +Telemetry._device = {} + +-- Device info polling +Telemetry._lastDevPoll = 0 + +-- ELRS status polling +Telemetry._lastStatusPoll = 0 + +-- Set once an ELRS_STATUS answer has arrived, so a match is asked about once +-- rather than polled. Cleared on any hasTelemetry edge in update(), and by +-- resetModelMatch() on widget create. +---@type boolean? +Telemetry._statusAnswered = nil + +-- hasTelemetry as last seen by update(), to detect connection edges. +Telemetry._wasConnected = false + +-- getTime() of the last sample, for the once-per-tick guard in update(). +---@type number? +Telemetry._sampledAt = nil + +-- ============================================================================ +-- Queries +-- ============================================================================ + +--- Whether a CRSF/ELRS module is available at all. +function Telemetry.hasModule() + return crsf.hasCrsfModule() +end + +--- Whether the link is up, i.e. the receiver is answering. +function Telemetry.isConnected() + return crsf.hasTelemetry +end + +--- True when a connected link is reporting a model mismatch. +--- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it +--- only means anything while connected: ungated, a stale flag paints a label +--- RED under text reading "--". +function Telemetry.isMismatch() + return crsf.hasTelemetry and Telemetry.modelMismatch +end + +--- RSSI of the antenna currently in use, or nil while unknown. +function Telemetry.activeRssi() + local link = Telemetry.link + return (link.ant == 1) and link.rssi2 or link.rssi1 +end + +--- Whether the RX reports a second antenna. +--- An RX only writes uplink_RSSI_2 when it has two RF paths -- a dual-radio RX +--- fills it every packet, a switched-antenna RX once it first selects antenna 2. +--- A single-antenna RX never touches it, so it arrives as 0 dBm, impossible for +--- a real signal. Same test ExpressLRS uses on the TX module's own screens. +function Telemetry.hasDiversity() + local rssi2 = Telemetry.link.rssi2 + return rssi2 ~= nil and rssi2 ~= 0 +end + +-- Power levels a module steps through, for the index the full-screen page +-- shows next to the milliwatts. +Telemetry.POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 } + +--- Map a power value in mW to a 0-based index. +function Telemetry.powerIndex(mw) + for k, v in ipairs(Telemetry.POWERS) do + if mw == v then + return k - 1 + end + end + return 7 +end + +--- Packet-rate name for the current RF mode, e.g. "250Hz". +function Telemetry.rfModeName() + local rfmd = Telemetry.link.rfmd + if rfmd == nil then + return nil + end + return RfModes.name(rfmd) +end + +-- ============================================================================ +-- Derived state +-- ============================================================================ + +--- Cell count detection heuristic. +local function checkCellCount(v) + -- once the cell count is the same X times in a row, stop updating + if (Telemetry._cellCntCnt or 0) > 5 then + return + end + + -- try to lock on to the cell count, so as the voltage sags we don't change S + local cellCnt = math.floor(v / 4.35) + 1 + -- Prevent lock on when no voltage is present + if (v / cellCnt) < 3.0 then + return + end + + if Telemetry.cellCnt ~= cellCnt then + Telemetry.cellCnt = cellCnt + Telemetry._cellCntCnt = 0 + else + -- The value has to change to count as an update + if Telemetry._cellLastV == v then + return + end + Telemetry._cellLastV = v + Telemetry._cellCntCnt = Telemetry._cellCntCnt + 1 + end +end + +--- Recompute the smoothed range percentage from the snapshot. +local function updateRangePct() + local rssi = Telemetry.activeRssi() + if rssi == nil then + Telemetry.rangePct = 0 + return + end + local rfmd = Telemetry.link.rfmd + local minrssi = (rfmd and RfModes.floor(rfmd)) or -128 + if rssi > -50 then + rssi = -50 + end + local pct = math.floor(100 * (rssi + 50) / (minrssi + 50) + 0.5) + local smooth = Telemetry._smoothRng or pct + if pct > smooth then + pct = smooth + ((pct > smooth + 8) and 4 or 1) + elseif pct < smooth then + pct = smooth - ((pct < smooth - 8) and 4 or 1) + end + Telemetry._smoothRng = pct + Telemetry.rangePct = pct +end + +--- Latch the last known GPS position, ignoring the "no fix" readings. +local function updateGps() + local gps = crsf.getSensorValue("GPS") + if gps and gps ~= 0 then + Telemetry.gps = gps + end +end + +-- ============================================================================ +-- Resets +-- ============================================================================ + +--- Clear the state that belongs to one connection, on the falling edge. +--- Cell count and range smoothing re-detect on the next battery instead of +--- latching forever. gps is deliberately kept. +local function resetConnection() + Telemetry._smoothRng = nil + Telemetry.rangePct = 0 + Telemetry.cellCnt = nil + Telemetry._cellCntCnt = nil + Telemetry._cellLastV = nil +end + +--- Forget the model-match verdict, so the next tick asks for a fresh one. +-- Cheap, idempotent and self-repairing, so create() may call it every time. +function Telemetry.resetModelMatch() + Telemetry.modelMismatch = nil + Telemetry._statusAnswered = nil +end + +--- Forget everything that belongs to the model that was loaded before. +-- This singleton lives in the widget Lua state, which only a boot or a resume +-- from shutdown rebuilds, while widget instances are rebuilt on every model +-- change -- so without this the previous model's aircraft would be on screen: +-- its last GPS position in particular, indistinguishable from a live fix. +-- The device cache and the RF tables stay; the module did not change. +-- +-- Clearing _sampledAt is load-bearing. create() runs per instance, so if a +-- sibling already sampled this tick, the update() that follows this reset +-- would be swallowed by the guard and paint a cleared snapshot for one frame. +function Telemetry.resetModel() + resetConnection() + Telemetry.resetModelMatch() + Telemetry.gps = nil + Telemetry._wasConnected = false + Telemetry._sampledAt = nil +end + +-- ============================================================================ +-- Outgoing requests +-- ============================================================================ + +--- Ask the TX module for its DEVICE_INFO if it is not cached yet. +-- Addressed to the module itself, so it stays off the air. EdgeTX pings once +-- at module init, but that answer lands before any widget's Lua queue exists +-- (queues are created lazily on the first crossfireTelemetryPop), so widgets +-- must ask themselves. Rate-limited to at most once per second, permanently +-- quiet once answered. +local function requestDeviceInfo() + if Telemetry._device.name then + return + end + local now = getTime() + if now - Telemetry._lastDevPoll < 100 then + return + end + Telemetry._lastDevPoll = now + crsf:pingDevices(crsf.CONST.ADDRESS_TX) +end + +-- Weakest link a status request will be spent on (rule 4 below). +local MODEL_MATCH_MIN_RSSI = -70 + +--- Whether a status request is worth the frame it costs, right now. +-- requestElrsStatus() is answered locally by the module, but the push still +-- replaces one RC-channels frame on the handset->module UART, so every rule +-- below weighs one answer against one frame. All of them must hold. +local function canRequestStatus(now) + -- 1. Connected. The module reports no model-match verdict without a link, + -- and the answer would carry nothing to display. + if not crsf.hasTelemetry then + return false + end + -- 2. ExpressLRS. The fieldId=0 request is an ELRS convention; a TBS module + -- answers device pings but never this, and would be asked forever. + if not Telemetry._device.isElrs then + return false + end + -- 3. No answer yet, or the last one was a mismatch. One answer settles a + -- match, but the module recomputes the flag as it answers and announces + -- nothing on its own, while every way out of a mismatch -- receiver + -- number, model select, the tool's Model Match switch, a rebind -- is + -- applied over a link the handset never sees drop. Latching there would + -- pin the warning on screen for the rest of the session. + if Telemetry._statusAnswered and not Telemetry.modelMismatch then + return false + end + -- 4. Strong link. A mismatch is caught next to the quad; out at range every + -- RC-channels frame is worth more than the answer is. Read from the + -- snapshot this tick's sample already filled. + local rssi = Telemetry.activeRssi() + if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then + return false + end + -- 5. At most once per second, which is as fresh as an answer gets: the + -- module latches its own packet counters on a 1 s watchdog. + return now - Telemetry._lastStatusPoll >= 100 +end + +--- Keep modelMismatch current, one request per second at the very most. +local function updateModelMatch() + local now = getTime() + if not canRequestStatus(now) then + return + end + Telemetry._lastStatusPoll = now + crsf:requestElrsStatus() +end + +-- ============================================================================ +-- Per-tick pump +-- ============================================================================ + +-- getTime() ticks every 10 ms and the widget loop runs every 50 ms, so a +-- window of 3 ticks deduplicates the instances of one frame without ever +-- swallowing the next frame -- an equality test would miss an instance loop +-- that straddles a tick boundary. +local SAMPLE_INTERVAL = 3 + +--- Sample every sensor and recompute everything derived from them, once per +--- tick however many instances call this. +--- Call right after drain() so the tick's frames are ingested and +--- hasTelemetry is current. Any hasTelemetry edge wipes the per-connection +--- state before updateModelMatch() runs: a stale modelMismatch can neither +--- survive a disconnect nor suppress the next connection's status poll via a +--- late-arriving answer. hasTelemetry only refreshes as a drain empties the +--- queue, so a dying connection's ELRS_STATUS always lands before the edge is +--- observed here. +function Telemetry.update() + local now = getTime() + if now - (Telemetry._sampledAt or -SAMPLE_INTERVAL) < SAMPLE_INTERVAL then + return + end + Telemetry._sampledAt = now + + local link = Telemetry.link + link.tpwr = crsf.getSensorValue("TPWR") + link.rfmd = crsf.getSensorValue("RFMD") + link.rssi1 = crsf.getSensorValue("1RSS") + link.rssi2 = crsf.getSensorValue("2RSS") + link.rqly = crsf.getSensorValue("RQly") + link.ant = crsf.getSensorValue("ANT") + link.vbat = crsf.getSensorValue("RxBt") + link.curr = crsf.getSensorValue("Curr") + link.fm = crsf.getSensorValue("FM") + link.sats = crsf.getSensorValue("Sats") + link.gspd = crsf.getSensorValue("GSpd") + link.alt = crsf.getSensorValue("Alt") + + local connected = crsf.hasTelemetry + if connected ~= Telemetry._wasConnected then + Telemetry._wasConnected = connected + Telemetry.resetModelMatch() + if not connected then + resetConnection() + end + end + + if connected then + updateGps() + updateRangePct() + if link.vbat then + checkCellCount(link.vbat) + end + end + + requestDeviceInfo() + updateModelMatch() +end + +-- ============================================================================ +-- Frame handlers +-- ============================================================================ + +-- DEVICE_INFO handler: caches module identity and selects the RF tables +local function onDeviceInfo(data) + local info = crsf:decodeDeviceInfo(data) + if info == nil or info.id ~= crsf.CONST.ADDRESS_TX then + return -- short frame (the ping retries) or not the TX module + end + Telemetry._device.name = info.name + Telemetry._device.isElrs = info.isElrs + RfModes.select(info.vMaj) +end + +-- ELRS_STATUS handler: latches the answer and updates modelMismatch +local function onElrsStatus(data) + local status = crsf:decodeElrsStatus(data) + if status == nil or status.id ~= crsf.CONST.ADDRESS_TX then + return + end + Telemetry._statusAnswered = true + Telemetry.modelMismatch = status.modelMismatch +end + +--- Route one frame into the state. Frames of other types are dropped: this +-- singleton is its widget's only queue consumer (pop is destructive per +-- instance), and the firmware delivers every instance its own copy of each +-- frame. Assignment only -- see invariant 3 in the header. +function Telemetry:_onFrame(command, data) + if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then + onDeviceInfo(data) + elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then + onElrsStatus(data) + end +end + +--- Drain the calling script instance's pop queue into the state. +function Telemetry.drain() + crsf.drain(Telemetry, Telemetry._onFrame) +end + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return Telemetry diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua new file mode 100644 index 0000000..35e8bb9 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -0,0 +1,257 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Display Components -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Telemetry); returns (Display, WidgetLayout). -- +-- -- +-- Display is the read model the ui/ files consume: zero-argument -- +-- formatters passed by reference as LVGL text/color callbacks, so they -- +-- are re-evaluated every frame without a rebuild. Every entry is a dot -- +-- function for that reason -- a colon method handed to LVGL would be -- +-- called with no receiver and fail at paint time. -- +-- -- +-- This is the whole vocabulary the view has. No ui/ file reaches past -- +-- it to the CRSF transport, and none of them formats a value itself. -- +--------------------------------------------------------------------------- + +local Telemetry = ... + +local Display = {} + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +-- ============================================================================ +-- Link state, as the view asks about it +-- ============================================================================ + +--- Whether the link is up. The view never reads the transport itself. +function Display.isConnected() + return Telemetry.isConnected() +end + +--- Whether to paint a mismatch warning. +function Display.isMismatch() + return Telemetry.isMismatch() +end + +--- Short status text when not operational or a warning is active. +--- Returns nil when connected with no warnings. +function Display.statusText() + if not Telemetry.hasModule() then + return "No CRSF module" + end + if not Telemetry.isConnected() then + return "No telemetry" + end + if Telemetry.modelMismatch then + return "Model Mismatch" + end + return nil +end + +--- Full-screen page subtitle: the same ladder, with a resting state. +function Display.pageSubtitle() + return Display.statusText() or "Telemetry" +end + +-- ============================================================================ +-- Values +-- ============================================================================ + +--- Link quality on its own, e.g. "100%", or "--" while disconnected. +function Display.lqValueText() + if not Telemetry.isConnected() then + return "--" + end + return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) +end + +--- Link quality as the hero label spells it, e.g. "LQ 100%". +function Display.lqText() + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) +end + +--- Active antenna RSSI, e.g. "-87dBm", or "" while unknown. +--- sep is the gap between the number and the unit: "" in tight minimized +--- layouts, " " where the full-screen rows have room for it. +function Display.rssiText(sep) + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + return table.concat({ tostring(rssi), sep or "", "dBm" }) +end + +--- The hero label: a status when there is one, otherwise link quality. +function Display.heroText() + return Display.statusText() or Display.lqText() +end + +--- Range percentage + RSSI text, e.g. "Range 69% -90dBm". +function Display.signalText() + if not Telemetry.isConnected() then + return "" + end + local parts = { table.concat({ "Range ", tostring(Telemetry.rangePct), "%" }) } + local rssi = Display.rssiText() + if rssi ~= "" then + parts[#parts + 1] = rssi + end + return table.concat(parts, " ") +end + +--- RF mode text, e.g. "250Hz". Narrow zones use this without the power suffix. +function Display.rfModeText() + if not Telemetry.isConnected() then + return "" + end + return Telemetry.rfModeName() or "" +end + +--- RF mode + TX power text, e.g. "250Hz 50mW". +function Display.rfDetailText() + local parts = { Display.rfModeText() } + local tpwr = Telemetry.link.tpwr + if Telemetry.isConnected() and tpwr then + parts[#parts + 1] = table.concat({ tostring(tpwr), "mW" }) + end + return table.concat(parts, " ") +end + +--- Battery text for minimized layouts, e.g. "Bat 4S 3.80V". +function Display.batteryText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "" + end + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) +end + +--- Battery text for the full-screen row, e.g. "4S 3.80V (15.20V)". +function Display.batteryTextVerbose() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + local cells = Telemetry.cellCnt + if cells then + return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) + end + return string.format("%.2fV", vbat) +end + +-- ============================================================================ +-- Colours and fonts +-- ============================================================================ + +--- Hero label colour: red only while a connected link reports a mismatch. +function Display.heroColor() + if Telemetry.isMismatch() then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +--- Map range percentage to a warning colour. +local function rangeColor(pct) + if pct > 90 then + return RED + end + if pct > 70 then + return ORANGE + end + return COLOR_THEME_SECONDARY1 +end + +--- Detail line colour: warns as the range percentage climbs, neutral while +--- there is no link to judge. +function Display.detailColor() + if not Telemetry.isConnected() then + return COLOR_THEME_SECONDARY1 + end + return rangeColor(Telemetry.rangePct) +end + +--- Hero label font for one tier of a screen's WidgetUI.fonts table. +--- Status text ("No CRSF module") is far longer than "LQ 100%", so tiers that +--- would overflow declare a smaller heroStatus and drop to it while a status +--- shows. Tiers without one get the constant back, so no callback runs per +--- frame. This is a factory, not a callback: call it when building. +function Display.heroFont(tier) + if not tier.heroStatus then + return tier.hero + end + return function() + if Display.statusText() then + return tier.heroStatus + end + return tier.hero + end +end + +-- ============================================================================ +-- Return components +-- ============================================================================ + +return Display, WidgetLayout diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 9fdc1de..b823796 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -4,8 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local Telemetry = ctx.Telemetry -local crsf = ctx.crsf +local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout @@ -27,33 +26,11 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, heroStatus = 0, detail = 0 }, } --- ============================================================================ --- Minimized display helpers --- ============================================================================ - -local function detailColor() - if not crsf.hasTelemetry then - return COLOR_THEME_SECONDARY1 - end - return Telemetry.rangeColor(Telemetry.rangePct) -end - -local function heroTextLq() - local status = Telemetry.statusText() - if status then - return status - end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ -local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, - Telemetry = Telemetry, -}) +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. @@ -71,8 +48,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = WidgetUI.fonts.sixth.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, }, }, @@ -85,8 +62,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -100,7 +77,7 @@ function WidgetUI.buildSixth(w, h, opa) y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -126,15 +103,15 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -151,7 +128,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -174,23 +151,23 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = Telemetry.heroFont(WidgetUI.fonts.third), - color = Telemetry.heroColor, - text = heroTextLq, + font = Display.heroFont(WidgetUI.fonts.third), + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } WidgetLayout.column(w, h, opa, rows) @@ -202,30 +179,30 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = Telemetry.heroFont(WidgetUI.fonts.full), - color = Telemetry.heroColor, - text = heroTextLq, + font = Display.heroFont(WidgetUI.fonts.full), + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.batteryText, + text = Display.batteryText, } end diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 423f7aa..32072b8 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -4,8 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local Telemetry = ctx.Telemetry -local crsf = ctx.crsf +local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout @@ -26,33 +25,11 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } --- ============================================================================ --- Minimized display helpers --- ============================================================================ - -local function detailColor() - if not crsf.hasTelemetry then - return COLOR_THEME_SECONDARY1 - end - return Telemetry.rangeColor(Telemetry.rangePct) -end - -local function heroTextLq() - local status = Telemetry.statusText() - if status then - return status - end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ -local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, - Telemetry = Telemetry, -}) +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) --- 1/6: single line — LQ (bold) + Range/dBm (colored). --- Portrait is narrower so skip RF mode/power. @@ -70,8 +47,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = WidgetUI.fonts.sixth.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, }, }, @@ -84,8 +61,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -111,15 +88,15 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -128,7 +105,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, } WidgetLayout.column(w, h, opa, rows) @@ -149,22 +126,22 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } WidgetLayout.column(w, h, opa, rows) @@ -176,30 +153,30 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = Telemetry.heroFont(WidgetUI.fonts.full), - color = Telemetry.heroColor, - text = heroTextLq, + font = Display.heroFont(WidgetUI.fonts.full), + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.batteryText, + text = Display.batteryText, } end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index bb56a20..45f7adc 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -4,8 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local Telemetry = ctx.Telemetry -local crsf = ctx.crsf +local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout @@ -27,33 +26,11 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } --- ============================================================================ --- Minimized display helpers --- ============================================================================ - -local function detailColor() - if not crsf.hasTelemetry then - return COLOR_THEME_SECONDARY1 - end - return Telemetry.rangeColor(Telemetry.rangePct) -end - -local function heroTextLq() - local status = Telemetry.statusText() - if status then - return status - end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ -local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, - Telemetry = Telemetry, -}) +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. @@ -71,8 +48,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = WidgetUI.fonts.sixth.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, }, }, @@ -85,8 +62,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -100,7 +77,7 @@ function WidgetUI.buildSixth(w, h, opa) y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -126,15 +103,15 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -151,7 +128,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -167,22 +144,22 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } WidgetLayout.column(w, h, opa, rows) @@ -194,30 +171,30 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = Telemetry.heroFont(WidgetUI.fonts.full), - color = Telemetry.heroColor, - text = heroTextLq, + font = Display.heroFont(WidgetUI.fonts.full), + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.batteryText, + text = Display.batteryText, } end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 62d4aeb..1f353f9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -4,8 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local Telemetry = ctx.Telemetry -local crsf = ctx.crsf +local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout @@ -28,33 +27,11 @@ WidgetUI.fonts = { full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } --- ============================================================================ --- Minimized display helpers --- ============================================================================ - -local function detailColor() - if not crsf.hasTelemetry then - return COLOR_THEME_SECONDARY1 - end - return Telemetry.rangeColor(Telemetry.rangePct) -end - -local function heroTextLq() - local status = Telemetry.statusText() - if status then - return status - end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ -local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, - Telemetry = Telemetry, -}) +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) --- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. @@ -72,8 +49,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = WidgetUI.fonts.sixth.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, }, }, @@ -86,8 +63,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -101,7 +78,7 @@ function WidgetUI.buildSixth(w, h, opa) y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -126,15 +103,15 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -150,7 +127,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -174,22 +151,22 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } WidgetLayout.column(w, h, opa, rows) @@ -201,30 +178,30 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = Telemetry.heroFont(WidgetUI.fonts.full), - color = Telemetry.heroColor, - text = heroTextLq, + font = Display.heroFont(WidgetUI.fonts.full), + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.batteryText, + text = Display.batteryText, } end diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index e9ddca6..e0ad4b4 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -4,8 +4,7 @@ --------------------------------------------------------------------------- local ctx = ... -local Telemetry = ctx.Telemetry -local crsf = ctx.crsf +local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout @@ -28,33 +27,11 @@ WidgetUI.fonts = { full = { hero = BOLD, detail = SMLSIZE }, } --- ============================================================================ --- Minimized display helpers --- ============================================================================ - -local function detailColor() - if not crsf.hasTelemetry then - return COLOR_THEME_SECONDARY1 - end - return Telemetry.rangeColor(Telemetry.rangePct) -end - -local function heroTextLq() - local status = Telemetry.statusText() - if status then - return status - end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) -end - -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ -local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ - crsf = crsf, - Telemetry = Telemetry, -}) +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) --- 1/6: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). --- Fixed-width columns prevent layout jumping when digit counts change. @@ -72,8 +49,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = WidgetUI.fonts.sixth.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, }, }, @@ -86,8 +63,8 @@ function WidgetUI.buildSixth(w, h, opa) type = lvgl.LABEL, y = lvgl.PAD_SMALL, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -101,7 +78,7 @@ function WidgetUI.buildSixth(w, h, opa) y = lvgl.PAD_SMALL, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfModeText, + text = Display.rfModeText, }, }, }, @@ -126,15 +103,15 @@ function WidgetUI.buildQuarter(w, h, opa) w = c1w, align = LEFT, font = WidgetUI.fonts.quarter.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, }, }, }, @@ -150,7 +127,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, }, }, }, @@ -166,22 +143,22 @@ function WidgetUI.buildThird(w, h, opa) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.third.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } WidgetLayout.column(w, h, opa, rows) @@ -194,29 +171,29 @@ local function appendDataRows(rows) type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.hero, - color = Telemetry.heroColor, - text = heroTextLq, + color = Display.heroColor, + text = Display.heroText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = WidgetUI.fonts.full.detail, - color = detailColor, - text = Telemetry.signalText, + color = Display.detailColor, + text = Display.signalText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.rfDetailText, + text = Display.rfDetailText, } rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = Telemetry.batteryText, + text = Display.batteryText, } end diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 97b9e9b..009e90c 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -1,17 +1,23 @@ --------------------------------------------------------------------------- -- ELRS Telemetry Widget - Shared Top Bar UI -- --- Used by all screen-specific UI files for the top bar layout. -- +-- Loaded via loadScript() from each per-screen ui/ file with -- +-- ({ Display }); returns the TopBarUI table. -- +-- -- +-- Two stacked lines and no background, so it composes onto the dark -- +-- header. Everything it shows is abbreviated to fit a top-bar slot, -- +-- which is why it spells out its own text ladder rather than reusing -- +-- the hero label's. -- --------------------------------------------------------------------------- local ctx = ... -local crsf = ctx.crsf -local Telemetry = ctx.Telemetry +local Display = ctx.Display local TopBarUI = {} ---- Top bar sits on the dark header, so it needs PRIMARY2 rather than Telemetry.heroColor's PRIMARY1. +--- The top bar sits on the dark header, so it needs PRIMARY2 where +--- Display.heroColor uses PRIMARY1. local function mismatchColor() - if Telemetry.isMismatch() then + if Display.isMismatch() then return RED end return COLOR_THEME_PRIMARY2 @@ -36,13 +42,13 @@ function TopBarUI.build(w, h) font = SMLSIZE, color = mismatchColor, text = function() - if not crsf.hasTelemetry then + if not Display.isConnected() then return "--" end - if Telemetry.isMismatch() then + if Display.isMismatch() then return "Model" end - return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) + return Display.lqText() end, }, { @@ -51,17 +57,13 @@ function TopBarUI.build(w, h) font = SMLSIZE, color = mismatchColor, text = function() - if not crsf.hasTelemetry then + if not Display.isConnected() then return "--" end - if Telemetry.isMismatch() then + if Display.isMismatch() then return "Mismatch" end - local rssi = Telemetry.getRssi(Telemetry.link) - if rssi == nil then - return "" - end - return table.concat({ tostring(rssi), "dBm" }) + return Display.rssiText() end, }, }, From 4d1b624f7ec62bea0ae918e80ff1626d05c8ad62 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:31:26 +0300 Subject: [PATCH 193/218] Extract the telemetry full-screen page The page was ~260 lines inline in loadable.lua, which shared nothing with the wiring around it. loadable.lua is 110 lines now and does what its name says: pick the layout, wire the components, drive them from the widget callbacks. The eight repeated "-- while the link is down" guards collapse into one whenConnected() wrapper. Which rows take it is deliberate and unchanged: only the link and power rows. The flight controller and GPS rows keep their own fallbacks, and latitude/longitude keep showing the last known position after the link drops. Loaded on first entry rather than with the widget, since only one widget can be full screen at a time and update() is not a hot path. The header describes when EdgeTX actually calls update() -- on construction, on entering and leaving full screen, on an options edit -- rather than claiming a rebuild policy the code does not have. --- src/WIDGETS/ELRSTelemetry/loadable.lua | 285 +------------------ src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua | 298 ++++++++++++++++++++ 2 files changed, 313 insertions(+), 270 deletions(-) create mode 100644 src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index bc4edad..12ac481 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -54,276 +54,6 @@ local WidgetUI = loadScript(uiPath)({ WidgetLayout = WidgetLayout, }) --- ============================================================================ --- Full-screen row helpers (shared across all screen sizes) --- ============================================================================ - --- Portrait screens get a narrower label column to leave more room for values. -local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 - ---- Wrap a value formatter so a row reads "--" while the link is down. ---- Only the Link Status and Power rows take this: the flight controller and ---- GPS rows have their own fallbacks, and latitude/longitude deliberately keep ---- showing the last known position after the link drops. -local function whenConnected(fn) - return function() - if not Telemetry.isConnected() then - return "--" - end - return fn() - end -end - -local function createDisplayRow(container, label, valueFn, colorFn) - container:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_ROW, - flexPad = 0, - children = { - { - type = lvgl.LABEL, - text = label, - color = COLOR_THEME_PRIMARY1, - w = lvgl.PERCENT_SIZE + LABEL_PCT, - y = lvgl.PAD_SMALL, - }, - { - type = lvgl.LABEL, - text = valueFn, - color = colorFn or COLOR_THEME_SECONDARY1, - w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), - y = lvgl.PAD_SMALL, - }, - }, - }) -end - -local function createSectionHeader(container, title) - container:build({ - { - type = lvgl.RECTANGLE, - w = lvgl.PERCENT_SIZE + 100, - h = lvgl.PAD_SMALL, - thickness = 0, - }, - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_PRIMARY1, - text = title, - }, - }) -end - --- ============================================================================ --- Full-screen LVGL layout (shared across all screen sizes) --- ============================================================================ - -local function buildFullScreen() - lvgl.clear() - - local pg = lvgl.page({ - title = "ExpressLRS", - subtitle = Display.pageSubtitle, - back = function() - lvgl.exitFullScreen() - end, - }) - - -- No module — show checklist instead of telemetry (matches expresslrs.lua NoModuleDialog) - if not Telemetry.hasModule() then - pg:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_MEDIUM, - children = { - { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, - { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, - { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, - { - type = lvgl.LABEL, - text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", - color = COLOR_THEME_DISABLED, - }, - }, - }) - return - end - - local fields = pg:rectangle({ - w = lvgl.PERCENT_SIZE + 100, - thickness = 0, - flexFlow = lvgl.FLOW_COLUMN, - }) - - -- Model mismatch warning banner - fields:build({ - { - type = lvgl.LABEL, - font = BOLD, - color = RED, - text = "Model Mismatch — RC commands not sent", - visible = Display.isMismatch, - }, - }) - - -- Link Status section - createSectionHeader(fields, "Link Status") - - createDisplayRow(fields, "RF Mode", Display.rfModeText) - - createDisplayRow(fields, "Link Quality", Display.lqValueText) - - createDisplayRow( - fields, - "RSSI 1", - whenConnected(function() - local rssi1 = Telemetry.link.rssi1 - if rssi1 == nil then - return "--" - end - return table.concat({ tostring(rssi1), " dBm" }) - end) - ) - - createDisplayRow( - fields, - "RSSI 2", - whenConnected(function() - local rssi2 = Telemetry.link.rssi2 - if rssi2 == nil then - return "--" - end - return table.concat({ tostring(rssi2), " dBm" }) - end), - function() - if not Telemetry.hasDiversity() then - return COLOR_THEME_DISABLED - end - return COLOR_THEME_SECONDARY1 - end - ) - - createDisplayRow( - fields, - "Active Antenna", - whenConnected(function() - if not Telemetry.hasDiversity() then - return "N/A" - end - -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX - -- module's own screen. The "Ant " prefix keeps this row from reading as that - -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above. - if Telemetry.link.ant == 0 then - return "Ant 1" - end - if Telemetry.link.ant == 1 then - return "Ant 2" - end - return "--" - end) - ) - - createDisplayRow( - fields, - "Range", - whenConnected(function() - return table.concat({ tostring(Telemetry.rangePct), "%" }) - end) - ) - - -- Power section - createSectionHeader(fields, "Power") - - createDisplayRow( - fields, - "TX Power", - whenConnected(function() - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return "--" - end - return table.concat({ tostring(tpwr), " mW" }) - end) - ) - - createDisplayRow( - fields, - "Power Index", - whenConnected(function() - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return "--" - end - return tostring(Telemetry.powerIndex(tpwr)) - end) - ) - - -- Flight Controller section - createSectionHeader(fields, "Flight Controller") - - createDisplayRow(fields, "Battery", Display.batteryTextVerbose) - - createDisplayRow(fields, "Current", function() - local curr = Telemetry.link.curr - if curr == nil or curr <= 0 then - return "--" - end - return string.format("%.2f A", curr) - end) - - createDisplayRow(fields, "Flight Mode", function() - local fm = Telemetry.link.fm - if fm == nil or fm == 0 then - return "--" - end - return tostring(fm) - end) - - -- GPS section - createSectionHeader(fields, "GPS") - - createDisplayRow(fields, "Satellites", function() - local sats = Telemetry.link.sats - if sats == nil then - return "--" - end - return tostring(sats) - end) - - createDisplayRow(fields, "Speed", function() - local gspd = Telemetry.link.gspd - if gspd == nil then - return "--" - end - return string.format("%.1f", gspd) - end) - - createDisplayRow(fields, "Altitude", function() - local alt = Telemetry.link.alt - if alt == nil then - return "--" - end - return tostring(alt) - end) - - createDisplayRow(fields, "Latitude", function() - if Telemetry.gps == nil then - return "--" - end - return tostring(Telemetry.gps.lat) - end) - - createDisplayRow(fields, "Longitude", function() - if Telemetry.gps == nil then - return "--" - end - return tostring(Telemetry.gps.lon) - end) -end - -- ============================================================================ -- Widget lifecycle -- ============================================================================ @@ -345,6 +75,21 @@ function wgt.refresh(_event, _touchState) wgt.background() end +local FullScreenUI + +--- Build the full-screen page, loading it the first time it is asked for. +--- Only one widget can be full screen at a time, so loading it eagerly would +--- leave a page builder resident in every instance that never shows one. +--- Nothing is lost by waiting: update() is not a hot path -- EdgeTX calls it +--- on construction, on entering and leaving full screen, and on an options +--- edit -- so a loadScript here costs nothing. +local function buildFullScreen() + if not FullScreenUI then + FullScreenUI = loadScript("/WIDGETS/ELRSTelemetry/ui/fullscreen.lua")(Telemetry, Display) + end + FullScreenUI.build() +end + function wgt.update(newOptions) wgt.options = newOptions if lvgl.isFullScreen() then diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua new file mode 100644 index 0000000..53372f5 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -0,0 +1,298 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Full-Screen Page -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Telemetry, Display); returns the FullScreenUI table. -- +-- -- +-- One layout for every screen size, because lvgl.page() handles the -- +-- responsive part: the link, power, flight controller and GPS rows, and -- +-- the no-module checklist in place of all of them. -- +-- -- +-- Loaded lazily, on the first full-screen entry. Only one widget can be -- +-- full screen at a time, so eager loading would leave this resident in -- +-- every other instance for nothing. -- +-- -- +-- build() runs on entry, not per frame: EdgeTX calls a widget's -- +-- update() when it is constructed, when it enters or leaves full -- +-- screen, and when its options are edited. Every value here is a -- +-- per-frame text or color callback, so nothing on the page needs a -- +-- rebuild to stay current. -- +--------------------------------------------------------------------------- + +local Telemetry, Display = ... + +local FullScreenUI = {} + +-- ============================================================================ +-- Row helpers +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for values. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +--- Wrap a value formatter so a row reads "--" while the link is down. +--- Only the link and power rows take this. The flight controller and GPS rows +--- have their own fallbacks, and latitude/longitude deliberately keep showing +--- the last known position after the link drops -- that is what you read when +--- you are looking for a model that stopped answering. +local function whenConnected(fn) + return function() + if not Telemetry.isConnected() then + return "--" + end + return fn() + end +end + +local function createDisplayRow(container, label, valueFn, colorFn) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + children = { + { + type = lvgl.LABEL, + text = label, + color = COLOR_THEME_PRIMARY1, + w = lvgl.PERCENT_SIZE + LABEL_PCT, + y = lvgl.PAD_SMALL, + }, + { + type = lvgl.LABEL, + text = valueFn, + color = colorFn or COLOR_THEME_SECONDARY1, + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + y = lvgl.PAD_SMALL, + }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, + }) +end + +-- ============================================================================ +-- The page +-- ============================================================================ + +function FullScreenUI.build() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = Display.pageSubtitle, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- No module — show checklist instead of telemetry. Decided at build time on + -- purpose: module presence is a Model Setup fact, and changing it means + -- leaving this page, which rebuilds it on the way back in. + if not Telemetry.hasModule() then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- Model mismatch warning banner + fields:build({ + { + type = lvgl.LABEL, + font = BOLD, + color = RED, + text = "Model Mismatch — RC commands not sent", + visible = Display.isMismatch, + }, + }) + + -- Link Status section + createSectionHeader(fields, "Link Status") + + createDisplayRow(fields, "RF Mode", Display.rfModeText) + + createDisplayRow(fields, "Link Quality", Display.lqValueText) + + createDisplayRow( + fields, + "RSSI 1", + whenConnected(function() + local rssi1 = Telemetry.link.rssi1 + if rssi1 == nil then + return "--" + end + return table.concat({ tostring(rssi1), " dBm" }) + end) + ) + + createDisplayRow( + fields, + "RSSI 2", + whenConnected(function() + local rssi2 = Telemetry.link.rssi2 + if rssi2 == nil then + return "--" + end + return table.concat({ tostring(rssi2), " dBm" }) + end), + function() + if not Telemetry.hasDiversity() then + return COLOR_THEME_DISABLED + end + return COLOR_THEME_SECONDARY1 + end + ) + + createDisplayRow( + fields, + "Active Antenna", + whenConnected(function() + if not Telemetry.hasDiversity() then + return "N/A" + end + -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX + -- module's own screen. The "Ant " prefix keeps this row from reading as that + -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above. + if Telemetry.link.ant == 0 then + return "Ant 1" + end + if Telemetry.link.ant == 1 then + return "Ant 2" + end + return "--" + end) + ) + + createDisplayRow( + fields, + "Range", + whenConnected(function() + return table.concat({ tostring(Telemetry.rangePct), "%" }) + end) + ) + + -- Power section + createSectionHeader(fields, "Power") + + createDisplayRow( + fields, + "TX Power", + whenConnected(function() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) + end) + ) + + createDisplayRow( + fields, + "Power Index", + whenConnected(function() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return tostring(Telemetry.powerIndex(tpwr)) + end) + ) + + -- Flight Controller section + createSectionHeader(fields, "Flight Controller") + + createDisplayRow(fields, "Battery", Display.batteryTextVerbose) + + createDisplayRow(fields, "Current", function() + local curr = Telemetry.link.curr + if curr == nil or curr <= 0 then + return "--" + end + return string.format("%.2f A", curr) + end) + + createDisplayRow(fields, "Flight Mode", function() + local fm = Telemetry.link.fm + if fm == nil or fm == 0 then + return "--" + end + return tostring(fm) + end) + + -- GPS section + createSectionHeader(fields, "GPS") + + createDisplayRow(fields, "Satellites", function() + local sats = Telemetry.link.sats + if sats == nil then + return "--" + end + return tostring(sats) + end) + + createDisplayRow(fields, "Speed", function() + local gspd = Telemetry.link.gspd + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) + + createDisplayRow(fields, "Altitude", function() + local alt = Telemetry.link.alt + if alt == nil then + return "--" + end + return tostring(alt) + end) + + createDisplayRow(fields, "Latitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lat) + end) + + createDisplayRow(fields, "Longitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lon) + end) +end + +return FullScreenUI From 7681746af3eb5d161320fd8b134e92cf737b4098 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:32:40 +0300 Subject: [PATCH 194/218] Load the VTX Admin full-screen page on first entry Only one widget can be full screen at a time, so a page builder loaded with the widget is resident in every instance that never shows one -- and VTX Admin is the widget users place many of. update() is not a hot path, so waiting until it is asked for costs nothing. Matches the telemetry widget, so the two keep the same shape. --- src/WIDGETS/ELRSVTXAdmin/loadable.lua | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index 5ee7e8f..100272e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -518,7 +518,6 @@ end -- ============================================================================ local VTXDisplay, WidgetLayout = loadScript("/WIDGETS/ELRSVTXAdmin/ui/display.lua")(VTXAdmin, PresetsStorage) -local FullScreenUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua")(VTXAdmin, PresetsStorage) -- ============================================================================ -- Screen detection and UI loading @@ -573,17 +572,32 @@ function wgt.refresh(_event, _touchState) wgt.background() end --- The full-screen page is built once, on entry. Everything it shows updates in --- place from there: every value is a per-frame callback or a control that polls --- its get() -- see the ui/fullscreen.lua header for the constraint that keeps --- that true. +local FullScreenUI + +--- Build the full-screen page, loading it the first time it is asked for. +--- Only one widget can be full screen at a time, so loading it eagerly would +--- leave a page builder resident in every instance that never shows one -- +--- and this widget is the one users place many of. update() is not a hot +--- path, so a loadScript here costs nothing. +--- +--- The page is built once, on entry. Everything it shows updates in place from +--- there: every value is a per-frame callback or a control that polls its +--- get() -- see the ui/fullscreen.lua header for the constraint that keeps +--- that true. +local function buildFullScreen() + if not FullScreenUI then + FullScreenUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua")(VTXAdmin, PresetsStorage) + end + FullScreenUI.build() +end + function wgt.update(newOptions) wgt.options = newOptions if lvgl.isFullScreen() then if VTXAdmin.isReady() then VTXAdmin.syncDesiredFromState() end - FullScreenUI.build() + buildFullScreen() else WidgetUI.build(wgt.zone, wgt.options) end From fc5e47069d7dd66145ee07d7db4cf12d028b30f2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:33:03 +0300 Subject: [PATCH 195/218] Drop the full-screen Power Index row It sat directly under TX Power showing the same fact in another unit, and it was not the module's own index: a linear scan over a hardcoded power table that answered 7 for anything it did not recognise, so a module with a power level outside the list reported the top index. TX Power in milliwatts is the number that means something to a pilot. --- src/WIDGETS/ELRSTelemetry/telemetry.lua | 14 -------------- src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua | 12 ------------ 2 files changed, 26 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua index 85820b0..0dd8ba4 100644 --- a/src/WIDGETS/ELRSTelemetry/telemetry.lua +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -139,20 +139,6 @@ function Telemetry.hasDiversity() return rssi2 ~= nil and rssi2 ~= 0 end --- Power levels a module steps through, for the index the full-screen page --- shows next to the milliwatts. -Telemetry.POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 } - ---- Map a power value in mW to a 0-based index. -function Telemetry.powerIndex(mw) - for k, v in ipairs(Telemetry.POWERS) do - if mw == v then - return k - 1 - end - end - return 7 -end - --- Packet-rate name for the current RF mode, e.g. "250Hz". function Telemetry.rfModeName() local rfmd = Telemetry.link.rfmd diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua index 53372f5..29fdea4 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -220,18 +220,6 @@ function FullScreenUI.build() end) ) - createDisplayRow( - fields, - "Power Index", - whenConnected(function() - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return "--" - end - return tostring(Telemetry.powerIndex(tpwr)) - end) - ) - -- Flight Controller section createSectionHeader(fields, "Flight Controller") From d122d62ffbe8034d40dccf0c321e2a40f4efc082 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:33:55 +0300 Subject: [PATCH 196/218] Scope the cached telemetry sensor IDs to the model getFieldInfo returns an ID that encodes a slot in the current model's sensor list -- api_general.cpp resolves it as g_model.telemetrySensors[(id - MIXSRC_FIRST_TELEM) / 3] -- but the cache keyed on the sensor name lives in the CRSF singleton, which is a global in the widget Lua state and so outlives every model change. So "RQly" resolved at slot 3 under one model kept addressing slot 3 under the next, where it may be a different sensor entirely, for the rest of the session. Nothing invalidated it, because until now nothing in the widget observed the model-change edge. The telemetry widget now has that edge, in resetModel(), so the cache is dropped there. Pre-existing; only visible after switching models with differently ordered sensor lists. --- docs/development.md | 2 +- src/SCRIPTS/ELRS/crsf.lua | 5 +++++ src/SCRIPTS/ELRS/sensors.lua | 13 +++++++++++++ src/WIDGETS/ELRSTelemetry/telemetry.lua | 1 + 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 6a1eaa4..8745df8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -69,7 +69,7 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too | `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | The color-LCD startup dialogs a tool can raise before it has a page -- the version gate and the missing-module notice. Both are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs | | `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | | `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | -| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue` | +| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue`. A cached ID addresses a slot in the model that was loaded when it was resolved, so a consumer that survives a model change must call `crsf.resetSensorCache()` on that edge | | `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | | `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index 6059d18..62f5839 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -139,6 +139,11 @@ end -- Read a telemetry sensor value by name (/SCRIPTS/ELRS/sensors.lua) CRSF.getSensorValue = sensors.getSensorValue +-- Drop the cached sensor IDs, which belong to the model that was loaded when +-- they were resolved. Whoever observes the model-change edge calls this; the +-- simulator mock reads by name and has no cache, so it needs no counterpart. +CRSF.resetSensorCache = sensors.resetCache + function CRSF.hasCrsfModule() for modIdx = 0, 1 do local mod = model.getModule(modIdx) diff --git a/src/SCRIPTS/ELRS/sensors.lua b/src/SCRIPTS/ELRS/sensors.lua index 4f365cf..93f8f50 100644 --- a/src/SCRIPTS/ELRS/sensors.lua +++ b/src/SCRIPTS/ELRS/sensors.lua @@ -6,6 +6,9 @@ -- Loaded by /SCRIPTS/ELRS/crsf.lua, which exposes the reader to every -- -- consumer as crsf.getSensorValue (and swaps in the simulator mock's -- -- reader when active). -- +-- -- +-- The cache is keyed by name but the IDs in it belong to one model, so -- +-- whoever owns the model-change edge must call resetCache(). -- --------------------------------------------------------------------------- local Sensors = {} @@ -13,6 +16,16 @@ local Sensors = {} -- Field ID cache (string sensor name -> numeric ID) Sensors._vCache = {} +--- Forget every cached ID. Call on a model change. +-- A telemetry field's ID encodes a slot in the *current* model's sensor list +-- (MIXSRC_FIRST_TELEM + 3 * index, resolved against g_model.telemetrySensors), +-- so an ID cached under one model addresses a different sensor under the next. +-- The cache lives in a singleton that outlives model changes, so nothing +-- invalidates it on its own. +function Sensors.resetCache() + Sensors._vCache = {} +end + --- Read a telemetry sensor value by name. -- Caches the getFieldInfo string->ID lookup once it succeeds; a sensor that is -- not discovered yet is retried on every call, so it starts reading as soon as diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua index 0dd8ba4..f0443f5 100644 --- a/src/WIDGETS/ELRSTelemetry/telemetry.lua +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -248,6 +248,7 @@ function Telemetry.resetModel() Telemetry.gps = nil Telemetry._wasConnected = false Telemetry._sampledAt = nil + crsf.resetSensorCache() end -- ============================================================================ From 36dc7a7e2c71541acce24b30eff1c812e83b223c Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:37:25 +0300 Subject: [PATCH 197/218] Settle the per-screen layout drift Four things that read as copy-paste divergence rather than intent: borderPad = 0 on the 1/4 tier's rows was present on sd and hd and absent on small and sd_tall, with nothing explaining why the same tier should be padded differently on two screens out of four. Now on all four. portrait keeps one, because its second row is a bare label rather than a box. small.lua read WidgetUI.fonts..hero directly where its siblings go through heroFont(), so a heroStatus added to its font table would have been silently ignored -- the shrink-while-a-status-shows contract would not have held on that screen alone. hd.lua wrote a bare 0 for a font where every sibling writes the name. It is STDSIZE; VTX Admin's hd.lua had the same wart next to a correct STDSIZE in the same table, which is what makes it a slip rather than a choice. portrait.lua's appendDataRows said it was shared by the 1/2 and 1/1 tiers, but portrait has no halfH breakpoint and no buildHalf. Verified: sd on TX16S and sd_tall on Jumper T15 both still render. --- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 2 ++ src/WIDGETS/ELRSTelemetry/ui/small.lua | 6 ++++-- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index b823796..017bf03 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -23,7 +23,7 @@ WidgetUI.fonts = { sixth = { hero = BOLD }, quarter = { hero = BOLD }, third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, heroStatus = 0, detail = 0 }, + full = { hero = MIDSIZE, heroStatus = STDSIZE, detail = 0 }, } -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 32072b8..3b28365 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -147,7 +147,7 @@ function WidgetUI.buildThird(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- Data rows shared by the 1/2 and 1/1 tiers: +--- Data rows for the 1/1 tier (portrait has no 1/2 tier): --- LQ, Range/RSSI, RF mode/power, battery. local function appendDataRows(rows) rows[#rows + 1] = { diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 1f353f9..78afdb1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -97,6 +97,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + borderPad = 0, children = { { type = lvgl.LABEL, @@ -121,6 +122,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + borderPad = 0, children = { { type = lvgl.LABEL, diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index e0ad4b4..fae285c 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -97,6 +97,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT + VCENTER, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + borderPad = 0, children = { { type = lvgl.LABEL, @@ -121,6 +122,7 @@ function WidgetUI.buildQuarter(w, h, opa) align = LEFT, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, + borderPad = 0, children = { { type = lvgl.LABEL, @@ -142,7 +144,7 @@ function WidgetUI.buildThird(w, h, opa) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = WidgetUI.fonts.third.hero, + font = Display.heroFont(WidgetUI.fonts.third), color = Display.heroColor, text = Display.heroText, } @@ -170,7 +172,7 @@ local function appendDataRows(rows) rows[#rows + 1] = { type = lvgl.LABEL, align = LEFT, - font = WidgetUI.fonts.full.hero, + font = Display.heroFont(WidgetUI.fonts.full), color = Display.heroColor, text = Display.heroText, } diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 05f2c86..dbf8cf1 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -27,7 +27,7 @@ WidgetUI.fonts = { quarter = { status = BOLD }, third = { status = MIDSIZE, cheatsheet = STDSIZE }, half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = MIDSIZE, detail = 0, cheatsheet = STDSIZE }, + full = { hero = MIDSIZE, detail = STDSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ From 62979c2c35479c08a8b8a93f5328d290815fb049 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 20:38:32 +0300 Subject: [PATCH 198/218] Document the widget components The architecture section tabled both tools and every library module but said nothing about the widgets at all, and the README elided their ui/ trees behind a "...". The layering the widgets actually use -- entry point, domain, wiring, read model, full-screen page, per-screen layouts -- was only discoverable by reading the loadScript calls. Records the two rules that hold across both widgets: the per-screen files are self-contained so shared presentation belongs in ui/display.lua, and no ui/ file receives the CRSF transport. Also records what sharing the telemetry state costs, so the next person to touch drain(), update() or a frame handler knows which of the three invariants they are standing on. --- README.md | 11 ++++++++--- docs/development.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3fe2020..3be0792 100644 --- a/README.md +++ b/README.md @@ -55,14 +55,19 @@ WIDGETS/ loadable.lua -- per-instance wiring and lifecycle ui/ display.lua -- read model shared by every layout below + fullscreen.lua -- full-screen page topbar.lua -- top-bar layout sd.lua sd_tall.lua hd.lua portrait.lua small.lua ELRSVTXAdmin/ - main.lua - loadable.lua + main.lua -- entry point + loadable.lua -- VTX config client and wiring + presets_storage.lua -- preset slot persistence presets.txt ui/ - ... + display.lua -- read model shared by every layout below + fullscreen.lua -- full-screen editor + topbar.lua -- top-bar layout + sd.lua sd_tall.lua hd.lua portrait.lua small.lua ``` The shared library `SCRIPTS/ELRS/` is required by both tools and both widgets. diff --git a/docs/development.md b/docs/development.md index 8745df8..8c77252 100644 --- a/docs/development.md +++ b/docs/development.md @@ -55,6 +55,34 @@ bounded UID retry reports that instead of polling forever): Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds. +The telemetry widget, `WIDGETS/ELRSTelemetry/`: + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and the EdgeTX widget descriptor. Bootstraps the singletons into globals and resets the model-scoped state when the model changes | +| `telemetry.lua` | Everything the widget knows about the link, in one owner: the frame drain, the DEVICE_INFO cache, the model-match status and the rules for when it may be asked for, the per-tick sensor snapshot, and every value derived from it. The only file in the widget that sees `crsf` | +| `rf_modes.lua` | Packet-rate names and rated RSSI floors, keyed by the module's firmware major version. Pure data plus its selector, apart from the policy because it versions on ExpressLRS's release clock | +| `loadable.lua` | One per placed widget: picks the layout for the screen, wires the components, drives them from the widget callbacks. Owns no state | +| `ui/display.lua` | The read model: zero-argument formatters the layouts pass to LVGL as `text`/`color` callbacks, plus `WidgetLayout`. The whole vocabulary the view has | +| `ui/fullscreen.lua` | The full-screen page, one layout for every screen size. Loaded on first entry | +| `ui/.lua` | Minimized layout per screen size, each with its own breakpoints, fonts and height tiers | +| `ui/topbar.lua` | Top-bar layout, shared by every screen file | + +`WIDGETS/ELRSVTXAdmin/` has the same shape, with `loadable.lua` holding the domain (it is per +instance, being a config client rather than shared link state) and `presets_storage.lua` alongside it. + +Two rules hold across both widgets. The per-screen `ui/.lua` files are deliberately +self-contained -- own breakpoints, own fonts, own tier builders -- so changing one screen cannot break +another; shared *presentation* goes in `ui/display.lua` instead. And no `ui/` file receives the CRSF +transport: the view asks the read model, which asks the domain. + +The telemetry widget's state is shared by every instance of it, because it describes the radio's link +rather than a widget. That costs three things, all stated in `telemetry.lua`'s header: `drain()` runs +per instance and ungated (each instance owns a firmware pop queue only it can empty), `update()` +samples at most once per tick (or the range smoother steps once per instance per frame), and the frame +handlers stay pure assignment (every instance is delivered its own copy of each frame, so the +singleton decodes each one N times on a radio and once in the simulator). + The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too: | Module | Purpose | From 151ad784906bb99973ecbe025a0f4c24ef0b360e Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 22:29:10 +0300 Subject: [PATCH 199/218] Report an unrated packet rate as having no sensitivity floor The rate tables carry a literal 0 for the rates ExpressLRS publishes no sensitivity figure for, and RfModes.floor returned it unchanged. Its own docstring says otherwise, and so does every caller: telemetry.lua guards with `or -128`, which 0 -- a truthy number -- walks straight past. The range percentage then divided by (0 + 50) on nine of the v4 rates and two of the v3 ones. --- src/WIDGETS/ELRSTelemetry/rf_modes.lua | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/WIDGETS/ELRSTelemetry/rf_modes.lua b/src/WIDGETS/ELRSTelemetry/rf_modes.lua index 78eb61b..6e8f71f 100644 --- a/src/WIDGETS/ELRSTelemetry/rf_modes.lua +++ b/src/WIDGETS/ELRSTelemetry/rf_modes.lua @@ -185,7 +185,14 @@ end --- the rate is unknown. Rates the tables carry as 0 are unrated, not 0 dBm. function RfModes.floor(rfmd) local floors = RfModes._floors - return floors and floors[rfmd + 1] + local dbm = floors and floors[rfmd + 1] + -- 0 is the tables' placeholder for a rate ExpressLRS publishes no figure + -- for. It has to become nil here: 0 is a truthy number, so callers guarding + -- with `or ` would take it for a real sensitivity of 0 dBm. + if dbm == 0 then + return nil + end + return dbm end -- ============================================================================ From f7c4ada078199193140b74104fdca7d89289a3db Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 22:36:07 +0300 Subject: [PATCH 200/218] Measure the link by its headroom instead of its range Range % answered a question nobody asks: it counted *down* from a full bar as the link got better, and it was a percentage of a distance that was never measured. What a pilot wants is how much signal is left before the receiver stops hearing, and ExpressLRS publishes exactly that -- a rated sensitivity floor per packet rate, already in rf_modes.lua. So the smoother now produces headroomPct with the polarity the right way up, the detail line prints the pair the comparison needs (-93 / -108 dBm) instead of an invented percentage, and its warning colour is driven by link margin in dB -- the unit the number is actually measured in -- on the same thresholds range % used. The full-screen page gains Sensitivity and Link Margin rows directly under the RSSI ones, sharing their unit so the arithmetic between the three is visible. Also samples TQly and TRSS, the downlink pair, and promotes the status ladder out of Display.statusText() into Telemetry.statusLevel(), so the words and the colours to come index one value rather than two copies of the same if-chain. --- src/WIDGETS/ELRSTelemetry/telemetry.lua | 102 +++++++++++++++----- src/WIDGETS/ELRSTelemetry/ui/display.lua | 66 ++++++++----- src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua | 24 ++++- 3 files changed, 143 insertions(+), 49 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua index f0443f5..3c3b83e 100644 --- a/src/WIDGETS/ELRSTelemetry/telemetry.lua +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -17,8 +17,8 @@ -- for the instances that did not sample this tick would leave those -- -- queues to fill and overflow. -- -- 2. update() samples once per tick however many instances call it. -- --- Without that, the range smoother would step once per instance per -- --- frame and settle N times faster on a screen with N widgets. -- +-- Without that, the headroom smoother would step once per instance -- +-- per frame and settle N times faster on a screen with N widgets. -- -- 3. The frame handlers stay pure assignment. Every instance is -- -- delivered its own copy of each frame, so on a radio this singleton -- -- decodes the same frame N times; a counter here would be N times -- @@ -43,8 +43,10 @@ local Telemetry = {} -- never changes, so consumers may cache a reference. Telemetry.link = {} --- Smoothed range percentage, recomputed once per tick from the snapshot. -Telemetry.rangePct = 0 +-- Smoothed signal headroom as a percentage of the current RF mode's usable +-- range, or nil while that range is unknown. Recomputed once per tick. +---@type number? +Telemetry.headroomPct = nil -- Detected battery cell count, or nil while no plausible pack voltage has -- been seen. Locks on once the same count survives a few readings. @@ -73,9 +75,9 @@ Telemetry.modelId = nil Telemetry._cellCntCnt = nil Telemetry._cellLastV = nil --- Unsmoothed range feed for rangePct. +-- Running smoother state for headroomPct. ---@type number? -Telemetry._smoothRng = nil +Telemetry._smoothHead = nil -- Device info cache (populated by the DEVICE_INFO handler): name, isElrs. -- Neither is displayed: name is the "already answered" latch that stops the @@ -123,12 +125,50 @@ function Telemetry.isMismatch() return crsf.hasTelemetry and Telemetry.modelMismatch end +-- The link's state as one value, worst first. The view turns it into words in +-- one place and into a colour in another; both index this, so the status line +-- and the status LED cannot end up disagreeing about what the link is doing. +Telemetry.STATUS = { + NO_MODULE = 1, + NO_TELEMETRY = 2, + MISMATCH = 3, + OK = 4, +} + +--- Where the link currently sits on the STATUS ladder. +function Telemetry.statusLevel() + local STATUS = Telemetry.STATUS + if not Telemetry.hasModule() then + return STATUS.NO_MODULE + end + if not Telemetry.isConnected() then + return STATUS.NO_TELEMETRY + end + if Telemetry.modelMismatch then + return STATUS.MISMATCH + end + return STATUS.OK +end + --- RSSI of the antenna currently in use, or nil while unknown. function Telemetry.activeRssi() local link = Telemetry.link return (link.ant == 1) and link.rssi2 or link.rssi1 end +--- dB the active antenna sits above the RF mode's rated sensitivity floor, or +--- nil while either end is unknown. This is the number the headroom bar draws +--- and the full-screen page prints: RSSI on its own says nothing until you +--- know what the receiver can still hear at. +function Telemetry.marginDb() + local rssi = Telemetry.activeRssi() + local sens = Telemetry.link.sens + if rssi == nil or sens == nil then + return nil + end + return rssi - sens +end + --- Whether the RX reports a second antenna. --- An RX only writes uplink_RSSI_2 when it has two RF paths -- a dual-radio RX --- fills it every packet, a switched-antenna RX once it first selects antenna 2. @@ -179,27 +219,40 @@ local function checkCellCount(v) end end ---- Recompute the smoothed range percentage from the snapshot. -local function updateRangePct() +-- Above this the receiver is saturated and more signal buys nothing, so it is +-- where the headroom scale tops out rather than the strongest RSSI seen. +local RSSI_CEILING = -50 + +--- Recompute the smoothed signal headroom from the snapshot. +-- 0% puts the active antenna exactly on the RF mode's rated floor and 100% +-- puts it at the ceiling, so the scale recalibrates with the packet rate: the +-- same -80 dBm is comfortable at 25Hz and marginal at F1000. +-- Unknown at either end means there is no scale to place RSSI on, and a bar +-- drawn against a guessed floor would be worse than no bar. +local function updateHeadroom() local rssi = Telemetry.activeRssi() - if rssi == nil then - Telemetry.rangePct = 0 + local sens = Telemetry.link.sens + if rssi == nil or sens == nil then + Telemetry.headroomPct = nil + Telemetry._smoothHead = nil return end - local rfmd = Telemetry.link.rfmd - local minrssi = (rfmd and RfModes.floor(rfmd)) or -128 - if rssi > -50 then - rssi = -50 + if rssi > RSSI_CEILING then + rssi = RSSI_CEILING + elseif rssi < sens then + -- The floor is a rating, not a wall: a receiver still reports RSSI below + -- the figure it is rated to. Clamping keeps the percentage on scale. + rssi = sens end - local pct = math.floor(100 * (rssi + 50) / (minrssi + 50) + 0.5) - local smooth = Telemetry._smoothRng or pct + local pct = math.floor(100 * (rssi - sens) / (RSSI_CEILING - sens) + 0.5) + local smooth = Telemetry._smoothHead or pct if pct > smooth then pct = smooth + ((pct > smooth + 8) and 4 or 1) elseif pct < smooth then pct = smooth - ((pct < smooth - 8) and 4 or 1) end - Telemetry._smoothRng = pct - Telemetry.rangePct = pct + Telemetry._smoothHead = pct + Telemetry.headroomPct = pct end --- Latch the last known GPS position, ignoring the "no fix" readings. @@ -215,11 +268,11 @@ end -- ============================================================================ --- Clear the state that belongs to one connection, on the falling edge. ---- Cell count and range smoothing re-detect on the next battery instead of +--- Cell count and headroom smoothing re-detect on the next battery instead of --- latching forever. gps is deliberately kept. local function resetConnection() - Telemetry._smoothRng = nil - Telemetry.rangePct = 0 + Telemetry._smoothHead = nil + Telemetry.headroomPct = nil Telemetry.cellCnt = nil Telemetry._cellCntCnt = nil Telemetry._cellLastV = nil @@ -355,12 +408,17 @@ function Telemetry.update() link.rssi2 = crsf.getSensorValue("2RSS") link.rqly = crsf.getSensorValue("RQly") link.ant = crsf.getSensorValue("ANT") + link.tqly = crsf.getSensorValue("TQly") + link.trss = crsf.getSensorValue("TRSS") link.vbat = crsf.getSensorValue("RxBt") link.curr = crsf.getSensorValue("Curr") link.fm = crsf.getSensorValue("FM") link.sats = crsf.getSensorValue("Sats") link.gspd = crsf.getSensorValue("GSpd") link.alt = crsf.getSensorValue("Alt") + -- Resolved here rather than at every read: the floor only moves when the + -- packet rate does, and the view asks for it several times a frame. + link.sens = link.rfmd and RfModes.floor(link.rfmd) local connected = crsf.hasTelemetry if connected ~= Telemetry._wasConnected then @@ -373,7 +431,7 @@ function Telemetry.update() if connected then updateGps() - updateRangePct() + updateHeadroom() if link.vbat then checkCellCount(link.vbat) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index 35e8bb9..b53b087 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -91,19 +91,21 @@ function Display.isMismatch() return Telemetry.isMismatch() end +-- One word per rung of Telemetry.STATUS. Title case throughout, because +-- pageSubtitle() puts these under the full-screen page's own title, where +-- shouting would read wrong. The tiers that want a caps banner spell it out +-- themselves as a fixed label. +local STATUS_TEXT = { + [Telemetry.STATUS.NO_MODULE] = "No CRSF module", + [Telemetry.STATUS.NO_TELEMETRY] = "No telemetry", + [Telemetry.STATUS.MISMATCH] = "Model Mismatch", +} + --- Short status text when not operational or a warning is active. ---- Returns nil when connected with no warnings. +--- Returns nil when connected with no warnings, which is the OK rung having +--- no entry above rather than a case handled here. function Display.statusText() - if not Telemetry.hasModule() then - return "No CRSF module" - end - if not Telemetry.isConnected() then - return "No telemetry" - end - if Telemetry.modelMismatch then - return "Model Mismatch" - end - return nil + return STATUS_TEXT[Telemetry.statusLevel()] end --- Full-screen page subtitle: the same ladder, with a resting state. @@ -144,17 +146,23 @@ function Display.heroText() return Display.statusText() or Display.lqText() end ---- Range percentage + RSSI text, e.g. "Range 69% -90dBm". +--- Active RSSI against the rate's rated floor, e.g. "-90 / -112 dBm". +--- The pair is the point: RSSI alone says nothing until you know what the +--- receiver can still hear at, and that figure moves with the packet rate. +--- Drops to the reading alone when the rate is unrated. function Display.signalText() if not Telemetry.isConnected() then return "" end - local parts = { table.concat({ "Range ", tostring(Telemetry.rangePct), "%" }) } - local rssi = Display.rssiText() - if rssi ~= "" then - parts[#parts + 1] = rssi + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + local sens = Telemetry.link.sens + if sens == nil then + return table.concat({ tostring(rssi), " dBm" }) end - return table.concat(parts, " ") + return table.concat({ tostring(rssi), " / ", tostring(sens), " dBm" }) end --- RF mode text, e.g. "250Hz". Narrow zones use this without the power suffix. @@ -213,24 +221,32 @@ function Display.heroColor() return COLOR_THEME_PRIMARY1 end ---- Map range percentage to a warning colour. -local function rangeColor(pct) - if pct > 90 then +-- Link margin in dB above the rated floor. At or below MARGIN_CRIT the +-- receiver is at the edge of what it can hear; above MARGIN_WARN there is +-- room left to fly into. Same thresholds the range percentage used, the right +-- way up and in the unit the number is actually measured in. +local MARGIN_CRIT = 10 +local MARGIN_WARN = 30 + +--- Map link margin to a warning colour. +local function marginColor(db) + if db <= MARGIN_CRIT then return RED end - if pct > 70 then + if db <= MARGIN_WARN then return ORANGE end return COLOR_THEME_SECONDARY1 end ---- Detail line colour: warns as the range percentage climbs, neutral while ---- there is no link to judge. +--- Detail line colour: warns as the link margin shrinks, neutral while there +--- is no margin to judge -- no link, or a rate with no published floor. function Display.detailColor() - if not Telemetry.isConnected() then + local db = Telemetry.marginDb() + if db == nil then return COLOR_THEME_SECONDARY1 end - return rangeColor(Telemetry.rangePct) + return marginColor(db) end --- Hero label font for one tier of a screen's WidgetUI.fonts table. diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua index 29fdea4..463e9ec 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -197,14 +197,34 @@ function FullScreenUI.build() end) ) + -- Sensitivity and Link Margin sit directly under the RSSI rows and share + -- their unit, so the arithmetic between the three is visible: this is what + -- the receiver is rated to hear, and this is how far above it you are. createDisplayRow( fields, - "Range", + "Sensitivity", whenConnected(function() - return table.concat({ tostring(Telemetry.rangePct), "%" }) + local sens = Telemetry.link.sens + if sens == nil then + return "--" + end + return table.concat({ tostring(sens), " dBm @ ", Display.rfModeText() }) end) ) + createDisplayRow( + fields, + "Link Margin", + whenConnected(function() + local db = Telemetry.marginDb() + if db == nil then + return "--" + end + return string.format("%+d dB", db) + end), + Display.detailColor + ) + -- Power section createSectionHeader(fields, "Power") From 8970df515160c7443068e4db0416c659efd6d73f Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 22:39:00 +0300 Subject: [PATCH 201/218] Mock the downlink pair and a rate with no rated floor TQly and TRSS are the RX->TX telemetry path, and nothing could exercise them because no scenario served them. They sit a few dB behind the uplink everywhere, which is the asymmetry worth showing: the receiver answers at a fraction of the module's power. The unrated_rate scenario reaches the other untested branch. Every connected scenario ran RFMD 7, which is rated, so the fallback for a packet rate ExpressLRS publishes no sensitivity figure for was unreachable in the simulator -- which is how RfModes.floor came to return 0 for it unnoticed. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 52 +++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 95c2832..6175061 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -44,6 +44,10 @@ local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() -- Shows armed warning in subtitle. -- "single_antenna" RX with a single RF path: 2RSS pinned to 0, so the -- widgets report no diversity. +-- "unrated_rate" TX + RX connected on a packet rate ExpressLRS publishes +-- no receiver sensitivity for. There is no floor to +-- measure RSSI against, so anything drawn against one has +-- to fall back instead of scaling off a guess. -- "slow_loading" TX + RX connected but PARAMETER_READ responses are -- delayed by ~2 seconds each. Tests how the UI renders -- during slow field discovery (e.g. "Loading..." states @@ -1169,6 +1173,7 @@ local function getElrsFlags() or config.scenario == "slow_loading" or config.scenario == "single_antenna" or config.scenario == "weak_link" + or config.scenario == "unrated_rate" then flags = 0x01 -- connected else @@ -1622,6 +1627,10 @@ local moduleFound = (config.scenario ~= "no_module") local txModuleTelemetry = { TPWR = 50 } +-- TQly/TRSS are the downlink pair: the RX->TX telemetry path, reported by the +-- handset's own receiver. They run a few dB behind the uplink in every +-- scenario because the receiver transmits at a fraction of the module's power, +-- which is the asymmetry the widget exists to show. local scenarioTelemetry = { normal = { TPWR = 50, @@ -1630,6 +1639,8 @@ local scenarioTelemetry = { ["2RSS"] = -93, RQly = 99, ANT = 1, + TQly = 100, + TRSS = -95, RxBt = 15.2, Curr = 12.5, FM = "ACRO", @@ -1647,6 +1658,8 @@ local scenarioTelemetry = { ["2RSS"] = 0, RQly = 97, ANT = 0, + TQly = 100, + TRSS = -91, RxBt = 15.1, Curr = 11.0, FM = "ACRO", @@ -1662,6 +1675,8 @@ local scenarioTelemetry = { ["2RSS"] = -82, RQly = 100, ANT = 0, + TQly = 100, + TRSS = -83, RxBt = 14.8, Curr = 28.5, FM = "ACRO", @@ -1670,6 +1685,27 @@ local scenarioTelemetry = { Alt = 85, GPS = { lat = 54.7050, lon = 25.3100 }, }, + -- A packet rate ExpressLRS publishes no sensitivity figure for (v3 index 18, + -- "9K1000", carried in the tables as 0). There is no floor to measure + -- against, so everything derived from one has to fall back rather than draw + -- a bar against a guessed number. + unrated_rate = { + TPWR = 250, + RFMD = 18, + ["1RSS"] = -79, + ["2RSS"] = -84, + RQly = 98, + ANT = 0, + TQly = 100, + TRSS = -88, + RxBt = 15.4, + Curr = 9.8, + FM = "ACRO", + Sats = 10, + GSpd = 18.2, + Alt = 96, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, -- Bench-realistic signal: a mismatch is caught next to the quad, and the -- active-antenna RSSI must clear the model-match poll's -70 dBm gate. model_mismatch = { @@ -1679,6 +1715,8 @@ local scenarioTelemetry = { ["2RSS"] = -58, RQly = 95, ANT = 1, + TQly = 100, + TRSS = -61, RxBt = 15.8, Curr = 0.5, }, @@ -1691,6 +1729,8 @@ local scenarioTelemetry = { ["2RSS"] = -58, RQly = 95, ANT = 1, + TQly = 100, + TRSS = -61, RxBt = 15.8, Curr = 0.5, }, @@ -1704,6 +1744,8 @@ local scenarioTelemetry = { ["2RSS"] = -58, RQly = 95, ANT = 1, + TQly = 100, + TRSS = -61, RxBt = 15.8, Curr = 0.5, }, @@ -1716,6 +1758,8 @@ local scenarioTelemetry = { ["2RSS"] = -88, RQly = 60, ANT = 0, + TQly = 62, + TRSS = -97, RxBt = 15.2, Curr = 12.5, FM = "ACRO", @@ -1732,6 +1776,8 @@ local scenarioTelemetry = { ["2RSS"] = -93, RQly = 99, ANT = 1, + TQly = 100, + TRSS = -95, RxBt = 15.2, Curr = 12.5, }, @@ -1743,6 +1789,8 @@ local scenarioTelemetry = { ["2RSS"] = -93, RQly = 99, ANT = 1, + TQly = 100, + TRSS = -95, RxBt = 15.2, Curr = 12.5, FM = "ACRO", @@ -1759,6 +1807,8 @@ local scenarioTelemetry = { ["2RSS"] = -93, RQly = 99, ANT = 1, + TQly = 100, + TRSS = -95, RxBt = 15.2, Curr = 12.5, FM = "ACRO", @@ -1774,7 +1824,9 @@ local scenarioTelemetry = { local sensorJitter = { ["1RSS"] = 3, -- +/- 3 dBm ["2RSS"] = 3, + TRSS = 3, RQly = 2, -- +/- 2% + TQly = 2, RxBt = 0.05, -- +/- 0.05V Curr = 2.0, -- +/- 2A GSpd = 3.0, From 61e9c709d1633949cf379ab66a0fbda78e6dcca1 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 23:07:05 +0300 Subject: [PATCH 202/218] Draw the 1/1 tier instead of listing it The minimized widget was four left-aligned labels on a flat rectangle, with no bar, gauge, rule or indicator anywhere in src/WIDGETS. This gives the 1/1 tier the instruments the data has always deserved: a status LED, the uplink as a headline LQ over two full-width bars, a stepped TX power meter, antenna cells, and a captioned row for the downlink and battery. The uplink is deliberately unheadered. It is what you fly on -- a degraded uplink loses control where a degraded downlink only makes the numbers stale -- so it takes the upper body in a register nothing else competes with, and the downlink is one captioned row underneath. ui/components.lua carries the drawing and the composition, so each screen file's buildFull is the four lines that differ: the hero font and the width gate. Everything is label, rectangle and circle, the only types lvgl.build() will accept in a widget zone, and nothing calls build() until the tree is complete. Two things measured rather than assumed. Font line heights come from lcd.sizeText, which still works under LVGL, because the 800x480 target ships different fonts and a scaled 480x272 number would be wrong there. And whether the downlink and battery groups share a row is decided by their measured widths, not a breakpoint -- on the same TX16S the answer differs between a 396px zone and a 198px one, and the ladder takes the dBm unit off first, then the pack total, then splits the rows, and only then drops the DOWNLINK header, which it can do at all because TQly and TRSS name themselves. --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 14 + src/WIDGETS/ELRSTelemetry/loadable.lua | 2 + src/WIDGETS/ELRSTelemetry/telemetry.lua | 5 +- src/WIDGETS/ELRSTelemetry/ui/components.lua | 640 ++++++++++++++++++++ src/WIDGETS/ELRSTelemetry/ui/display.lua | 252 +++++++- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 33 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 33 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 35 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 33 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 33 +- 10 files changed, 1011 insertions(+), 69 deletions(-) create mode 100644 src/WIDGETS/ELRSTelemetry/ui/components.lua diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 6175061..0a3b14a 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -1833,6 +1833,13 @@ local sensorJitter = { Alt = 5, } +-- Upper bounds the jitter may not cross, for sensors whose range is fixed by +-- what they measure rather than by the scenario. +local sensorCeiling = { + RQly = 100, + TQly = 100, +} + -- Per-scenario sensors that step through a fixed sequence instead of jittering, -- so both branches of an enum sensor are reachable within one simulator run. -- Takes precedence over sensorJitter and over the scenario's base value; one @@ -1898,6 +1905,13 @@ local function updateTelemetryCache() if jit == math.floor(jit) then val = math.floor(val + 0.5) end + -- A link quality is a percentage of packets received, so it cannot + -- exceed 100. Jittering a base of 99 was handing the widgets 101, which + -- is not a reading any receiver can produce. + local ceiling = sensorCeiling[sensorId] + if ceiling and val > ceiling then + val = ceiling + end telemetryCache[sensorId] = val else telemetryCache[sensorId] = base diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index 12ac481..bae8740 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -19,6 +19,7 @@ local zone, options, Telemetry = ... -- ============================================================================ local Display, WidgetLayout = loadScript("/WIDGETS/ELRSTelemetry/ui/display.lua")(Telemetry) +local Components = loadScript("/WIDGETS/ELRSTelemetry/ui/components.lua")(Display) -- ============================================================================ -- Screen detection and UI loading @@ -52,6 +53,7 @@ local WidgetUI = loadScript(uiPath)({ Display = Display, bgOpacity = bgOpacity, WidgetLayout = WidgetLayout, + Components = Components, }) -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua index 3c3b83e..cb8a04e 100644 --- a/src/WIDGETS/ELRSTelemetry/telemetry.lua +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -221,7 +221,10 @@ end -- Above this the receiver is saturated and more signal buys nothing, so it is -- where the headroom scale tops out rather than the strongest RSSI seen. -local RSSI_CEILING = -50 +-- Public because it is the headroom bar's right endpoint, and a layout file +-- printing its own -50 would be a second copy of the same decision. +Telemetry.RSSI_CEILING = -50 +local RSSI_CEILING = Telemetry.RSSI_CEILING --- Recompute the smoothed signal headroom from the snapshot. -- 0% puts the active antenna exactly on the RF mode's rated floor and 100% diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua new file mode 100644 index 0000000..ce6cb4e --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -0,0 +1,640 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Drawing Components -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Display); returns the Components table. -- +-- -- +-- Two layers. Elements are the shapes -- panel, bar, led, segments, -- +-- rule, label. Blocks are what a tier actually places: a status strip, -- +-- the uplink panel, a row of captioned groups. Keeping the blocks here -- +-- is what makes five per-screen files affordable: a tier builder stays -- +-- a short vertical composition instead of eight inlined element -- +-- definitions copied five times. -- +-- -- +-- Nothing here calls lvgl.build(). Every function appends descriptor -- +-- tables to a list its caller owns, and the tier builds once at the -- +-- end: one C traversal, no retained handles, and no giant nested -- +-- literal for the .luac precompiler to choke on. -- +-- -- +-- Every block returns the next free y, so a tier reads top to bottom. -- +-- -- +-- Only label, rectangle and circle appear below. The others lvgl offers -- +-- are either fullscreen-only -- silently skipped in a widget zone, and -- +-- lvgl.build() fails without a word when that happens -- or, like -- +-- hline, have no thickness and so cannot draw a rule. -- +--------------------------------------------------------------------------- + +local Display = ... + +local Components = {} + +-- ============================================================================ +-- Metrics +-- ============================================================================ + +--- Measure the font line heights a layout needs. +-- Measured rather than tabulated because the 800x480 target ships different +-- font files: a height taken on 480x272 and scaled by lvgl.LCD_SCALE would be +-- confidently wrong there. lcd.sizeText has no LCD-buffer guard, so it is one +-- of the few lcd.* calls that still works under LVGL. +-- "0" is measured rather than the real strings: line height is a property of +-- the font, and using content would make the metric move with the data. +function Components.measure() + local m = { + pad = lvgl.PAD_SMALL, + gap = lvgl.PAD_TINY, + } + m.sml = select(2, lcd.sizeText("0", SMLSIZE)) + m.bold = select(2, lcd.sizeText("0", BOLD)) + m.mid = select(2, lcd.sizeText("0", MIDSIZE)) + return m +end + +--- Width of a string in a font, for reserving a column. +function Components.textWidth(s, font) + return (lcd.sizeText(s, font)) +end + +-- ============================================================================ +-- Elements +-- ============================================================================ + +--- Background panel. Returns the child list to compose into, so everything +--- placed afterwards is positioned relative to the panel's own origin. +--- Two rectangles because a rectangle is filled or bordered, never both. +function Components.panel(dst, rect, spec) + local children = {} + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = rect.y, + w = rect.w, + h = rect.h, + color = COLOR_THEME_PRIMARY2, + opacity = spec.opacity, + filled = true, + } + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = rect.y, + w = rect.w, + h = rect.h, + color = COLOR_THEME_SECONDARY2, + thickness = 1, + children = children, + } + return children +end + +--- A 1px horizontal rule. +--- A filled rectangle, not an hline: hline has no thickness property, an +--- unknown key raises, and lvgl.build() swallows the error along with the +--- whole tree. +--- COLOR_THEME_SECONDARY2 is the obvious choice and the wrong one -- it is +--- what the panel edge uses, and against COLOR_THEME_PRIMARY2 at 1px it +--- simply does not appear. +function Components.rule(dst, rect, y) + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = rect.w, + h = 1, + color = COLOR_THEME_DISABLED, + filled = true, + } + return y + 1 +end + +--- A text label. Thin wrapper, so a tier never hand-writes a descriptor. +function Components.label(dst, spec) + dst[#dst + 1] = { + type = lvgl.LABEL, + x = spec.x, + y = spec.y, + w = spec.w, + align = spec.align or LEFT, + font = spec.font, + color = spec.color or COLOR_THEME_PRIMARY1, + text = spec.text, + visible = spec.visible, + } +end + +--- A horizontal meter: a track with a fill that grows from the left. +--- spec.pct returns 0-100; spec.color is the fill colour; spec.visible, when +--- given, hides the fill so the bare track shows through. +function Components.bar(dst, rect, y, spec) + local h = spec.h + local w = rect.w + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = w, + h = h, + color = COLOR_THEME_DISABLED, + filled = true, + } + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = w, + h = h, + color = spec.color, + filled = true, + -- Size closures go through luaL_checkunsigned, so this must never return + -- a negative, and never 0 either: 0 is a literal zero-pixel object rather + -- than "no width". An empty bar hides via spec.visible instead. + size = function() + local fill = math.floor(w * (spec.pct() or 0) / 100) + if fill < 1 then + fill = 1 + elseif fill > w then + fill = w + end + return fill, h + end, + visible = spec.visible, + } + return y + h +end + +--- The status LED. x and y are the centre, not a corner. +function Components.led(dst, spec) + dst[#dst + 1] = { + type = lvgl.CIRCLE, + x = spec.x, + y = spec.y, + radius = spec.radius, + filled = true, + color = spec.color, + } +end + +--- A stepped meter: a continuous track and fill, cut into cells by separators +--- drawn on top in the panel colour. +--- One size closure for the whole meter rather than one per cell, which is +--- the difference between 1 and 8 pcalls a frame. The separators are static, +--- so the fill reads as discrete steps without any cell knowing the value. +--- Outlining the unlit cells instead was tried and is not visible: a +--- COLOR_THEME_SECONDARY2 hairline disappears into COLOR_THEME_PRIMARY2 on +--- the light themes, which took the meter's scale with it -- and a meter +--- whose empty half cannot be seen is just a short bar. +function Components.segments(dst, rect, y, spec) + local n = spec.count + local cw = math.floor(rect.w / n) + local h = spec.h + local total = cw * n + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = total, + h = h, + color = COLOR_THEME_DISABLED, + filled = true, + } + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = total, + h = h, + color = spec.color, + filled = true, + size = function() + local lit = spec.steps() + if lit > n then + lit = n + end + return math.max(1, lit * cw), h + end, + -- At zero steps the size closure would still have to return 1px, so the + -- meter empties by hiding the fill rather than by shrinking it. + visible = function() + return spec.steps() > 0 + end, + } + for i = 1, n - 1 do + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x + i * cw - 1, + y = y, + w = 1, + h = h, + color = COLOR_THEME_PRIMARY2, + filled = true, + } + end + return y + h +end + +--- Two antenna cells, the active one lit. +--- The best pixels-per-bit element either reference widget has: it says which +--- RF path is carrying the link in 20px, where a text row costs a whole line. +--- The second cell hides on a single-path receiver rather than sitting dark, +--- which would imply an antenna that is not there. +function Components.antenna(dst, rect, y, m) + local cell = m.sml - 4 + local gap = m.gap + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "ANT", + }) + local cx = rect.x + Components.textWidth("ANT ", SMLSIZE) + for i = 1, 2 do + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = cx + (i - 1) * (cell + gap), + y = y + 2, + w = cell, + h = cell, + color = Display.antColor(i), + filled = true, + visible = (i == 2) and Display.hasDiversity or nil, + } + end + return cx + 2 * cell + gap +end + +-- ============================================================================ +-- Blocks +-- ============================================================================ + +--- The status strip: LED, RF mode, optionally the power meter, antenna cells. +--- A model mismatch takes the whole strip. The binding is wrong, which is +--- worth the row -- and the elements it displaces take the inverse visible +--- rather than being left out, so the banner lands in the same rect and +--- nothing below it moves. +--- Only a mismatch gets a banner. "No telemetry" is what the widget shows on +--- the bench with the quad switched off; shouting about a resting state +--- teaches the eye to ignore the banner that matters. +function Components.statusStrip(dst, rect, y, m, spec) + local h = m.sml + 4 + local r = math.floor(m.sml / 2) - 1 + Components.led(dst, { + x = rect.x + r, + y = y + math.floor(h / 2), + radius = r, + color = Display.ledColor, + }) + local textX = rect.x + 2 * r + m.pad + + Components.label(dst, { + x = textX, + y = y, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY1, + text = Display.rfModeText, + visible = Display.isNotMismatch, + }) + Components.label(dst, { + x = textX, + y = y, + font = BOLD, + color = RED, + -- A constant label plus a bool closure, not a formatter: statusText() + -- keeps title case for the full-screen subtitle it also feeds, and a + -- string closure would be hashed every frame to say the same thing. + text = "MODEL MISMATCH!", + visible = Display.isMismatch, + }) + + -- Right-hand end, laid out from the right edge inward. + local antW = Components.textWidth("ANT ", SMLSIZE) + 2 * (m.sml - 4) + m.gap + local antX = rect.x + rect.w - antW + Components.antenna(dst, { x = antX }, y, m) + + -- The power meter fills the gap between the two only where there is one. + -- Measured against the widest rate name the tables carry, so a switch from + -- 25Hz to K1000Full never pushes the meter sideways. + if spec.power then + local modeW = Components.textWidth("K1000Full", SMLSIZE) + local powerX = textX + modeW + m.pad * 3 + Components.powerGroup(dst, { x = powerX, w = antX - powerX - m.pad * 3 }, y, m) + end + return y + h +end + +--- TX POWER as an inline group: header, stepped meter, value. +function Components.powerGroup(dst, rect, y, m) + local headW = Components.textWidth("TX POWER ", SMLSIZE) + local valW = Components.textWidth("2000 mW", SMLSIZE) + local meterW = rect.w - headW - valW - m.pad + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "TX POWER", + visible = Display.isNotMismatch, + }) + Components.segments(dst, { x = rect.x + headW, w = meterW }, y + 2, { + count = Display.powerStepCount(), + h = m.sml - 4, + color = COLOR_THEME_PRIMARY1, + steps = Display.powerSteps, + }) + Components.label(dst, { + x = rect.x + headW + meterW + m.pad, + y = y, + w = valW, + align = RIGHT, + font = SMLSIZE, + text = Display.powerText, + visible = Display.isNotMismatch, + }) + return y + m.sml +end + +--- The uplink panel: the widget's identity. +--- Unheadered on purpose. The uplink is what you fly on -- if it degrades you +--- lose control, where a degraded downlink only makes the numbers stale -- so +--- it takes the whole upper body in a visual register nothing else competes +--- with. Naming it would imply a peer that does not exist here; the downlink +--- is a single captioned row further down. +--- LQ and RSSI each get a full-width bar under their own caption. The two +--- scales differ -- LQ is a fixed 0-100, the headroom track recalibrates with +--- the packet rate -- and comparing their lengths is the useful part: a full +--- LQ bar over a half-full headroom bar reads as "perfect right now, less +--- margin than there could be", which is exactly what is worth seeing early. +--- spec.lqBar and spec.headroomBar drop either bar for the smaller tiers; +--- spec.endpoints adds the headroom scale's numbers where width allows. +function Components.uplinkPanel(dst, rect, y, m, spec) + Components.label(dst, { + x = rect.x, + y = y, + font = spec.lqFont, + color = Display.lqBarColor, + text = Display.lqText, + }) + y = y + spec.lqH + if spec.lqBar then + y = Components.bar(dst, rect, y, { + h = spec.barH, + pct = Display.lqPct, + color = Display.lqBarColor, + }) + m.gap + end + + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "RSSI", + }) + Components.label(dst, { + x = rect.x, + y = y, + w = rect.w, + align = RIGHT, + font = SMLSIZE, + color = Display.detailColor, + text = Display.signalText, + }) + y = y + m.sml + + if spec.headroomBar then + y = Components.bar(dst, rect, y, { + h = spec.barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + -- No rated floor means no scale, and a bar drawn against a guessed one + -- is worse than no bar. The track stays so the row keeps its height. + visible = Display.hasHeadroom, + }) + if spec.endpoints then + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.sensText, + }) + Components.label(dst, { + x = rect.x, + y = y, + w = rect.w, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.ceilingText, + }) + y = y + m.sml + end + end + return y +end + +--- One row of `HEADER value value ...` groups laid side by side. +--- The inline header is what makes two groups share a row readably: a value +--- always belongs to the nearest header on its left. Groups get measured +--- widths, so a value gaining a digit never reflows the row. +--- groups is a list of { header = "DOWNLINK", w = n, values = { ... } }, where +--- each value is { text = fn, caption = "TQly" } and the caption is optional. +--- The downlink's captions are the sensor names EdgeTX itself puts in the +--- model's telemetry list, mixed case and all, so the row can be read straight +--- across to that list and to the module's own screen. +--- Width one group needs, measured against the widest string each of its +--- values can ever show. Columns sized this way never move when a reading +--- gains a digit, and a tier can ask before committing to a row. +function Components.groupWidth(m, group) + local w = 0 + if group.header ~= "" then + w = Components.textWidth(table.concat({ group.header, " " }), SMLSIZE) + end + for i = 1, #group.values do + local v = group.values[i] + if v.caption then + w = w + Components.textWidth(v.caption, SMLSIZE) + m.pad + end + w = w + Components.textWidth(v.sample, SMLSIZE) + m.pad * 2 + end + return w +end + +function Components.groupRow(dst, rect, y, m, groups) + -- Lay out on measured widths and hand the leftover to the gaps between + -- groups. Splitting the row evenly instead would give a captioned group the + -- same space as a bare one and overlap its caption with its own value. + local natural = 0 + for i = 1, #groups do + natural = natural + Components.groupWidth(m, groups[i]) + end + local spare = math.max(0, math.floor((rect.w - natural) / math.max(1, #groups))) + + local x = rect.x + for i = 1, #groups do + local g = groups[i] + local cx = x + -- An empty header drops the group name and lets the captions carry it. + -- Only the downlink may do that, and only because TQly and TRSS are + -- self-qualifying: the T prefix already means the transmitter end. + if g.header ~= "" then + Components.label(dst, { + x = x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = g.header, + }) + cx = x + Components.textWidth(table.concat({ g.header, " " }), SMLSIZE) + end + for j = 1, #g.values do + local v = g.values[j] + local capW = 0 + if v.caption then + Components.label(dst, { + x = cx, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = v.caption, + }) + capW = Components.textWidth(v.caption, SMLSIZE) + m.pad + end + local cellW = capW + Components.textWidth(v.sample, SMLSIZE) + m.pad * 2 + Components.label(dst, { + x = cx, + y = y, + w = cellW - m.pad, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY1, + text = v.text, + }) + cx = cx + cellW + end + x = cx + spare + end + return y + m.sml +end + +-- ============================================================================ +-- The 1/1 tier +-- ============================================================================ + +local function downlinkGroup(compact, headed) + return { + header = headed and "DOWNLINK" or "", + values = { + { caption = "TQly", text = Display.tqlyText, sample = "100 %" }, + { + caption = "TRSS", + text = compact and Display.trssText or Display.trssTextUnit, + sample = compact and "-105" or "-105 dBm", + }, + }, + } +end + +local function batteryGroup(compact) + local g = { + header = "BATTERY", + values = { + { text = Display.cellText, sample = "4S 3.75 V" }, + { text = Display.packText, sample = "15.20 V" }, + }, + } + if compact then + g.values[2] = nil + end + return g +end + +--- Fit the downlink and battery groups into the width there is. +--- A ladder, not a breakpoint: what fits depends on the header and column +--- widths in this screen's font, and the answer already differs between a +--- 396px zone and a 198px one on the same radio. +--- Whole values come off, never half a group -- a lone TQly with no TRSS +--- beside it reads as a downlink with no signal rather than as a row that ran +--- out of room. The DOWNLINK header is the last thing to go, and it can go at +--- all only because TQly and TRSS name themselves. +local function groupRows(w, m) + local function fits(a, b) + local total = Components.groupWidth(m, a) + if b then + total = total + Components.groupWidth(m, b) + end + return total <= w + end + + if fits(downlinkGroup(false, true), batteryGroup(false)) then + return { { downlinkGroup(false, true), batteryGroup(false) } } + end + if fits(downlinkGroup(true, true), batteryGroup(true)) then + return { { downlinkGroup(true, true), batteryGroup(true) } } + end + if fits(downlinkGroup(true, true)) then + return { { downlinkGroup(true, true) }, { batteryGroup(true) } } + end + return { { downlinkGroup(true, false) }, { batteryGroup(true) } } +end + +--- The whole 1/1 tier: status strip, uplink panel, group rows. +--- Lives here rather than in each screen file because after the components +--- carry the drawing there is nothing screen-specific left in it but the +--- fonts -- and five copies of the vertical arithmetic is exactly the drift +--- the per-screen split is supposed to avoid. +--- Height left over after the fixed rows goes into the bars and the air +--- around the rules, never to the bottom: a panel with a hole in it is what +--- "looks basic" means. +function Components.fullTier(w, h, opa, m, spec) + local wide = spec.wide + local pad = m.pad + local inner = { x = pad, w = w - pad * 2 } + local rows = groupRows(inner.w, m) + + -- Everything except the two bars and the air between blocks. This has to + -- match what the blocks below actually consume, AIR_GAPS included, or the + -- last row walks off the bottom of the panel. + local AIR_GAPS = 4 + local fixed = pad * 2 -- top and bottom + + (m.sml + 4) -- status strip + + 2 -- two rules + + spec.lqH -- LQ + + m.gap -- LQ bar to RSSI row + + m.sml -- RSSI row + + (wide and m.sml or 0) -- headroom endpoints + + m.sml * #rows + + (wide and 0 or m.sml + m.gap) -- narrow puts TX POWER on its own row + local slack = h - fixed + local barH = math.max(4, math.min(math.floor(slack * 0.3), 26)) + local air = math.max(m.gap, math.floor((slack - barH * 2) / AIR_GAPS)) + + local root = {} + local panel = Components.panel(root, { x = 0, y = 0, w = w, h = h }, { opacity = opa }) + + local y = Components.statusStrip(panel, inner, pad, m, { power = wide }) + y = Components.rule(panel, inner, y + air) + y = Components.uplinkPanel(panel, inner, y + air, m, { + lqFont = spec.lqFont, + lqH = spec.lqH, + barH = barH, + lqBar = true, + headroomBar = true, + endpoints = wide, + }) + y = Components.rule(panel, inner, y + air) + air + for i = 1, #rows do + y = Components.groupRow(panel, inner, y, m, rows[i]) + end + if not wide then + -- No room for the meter in a half-width strip, so it gets the last row. + Components.powerGroup(panel, inner, y + m.gap, m) + end + + lvgl.build(root) +end + +-- ============================================================================ +-- Return components +-- ============================================================================ + +return Components diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index b53b087..023b8a2 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -91,6 +91,19 @@ function Display.isMismatch() return Telemetry.isMismatch() end +--- The inverse, for the strip elements a mismatch banner displaces. They take +--- this rather than being left out, so the banner and the elements it hides +--- occupy the same rect and nothing reflows. +function Display.isNotMismatch() + return not Telemetry.isMismatch() +end + +--- Whether the RX reports a second antenna, so a layout can hide the cell +--- that would otherwise imply a path this receiver does not have. +function Display.hasDiversity() + return Telemetry.hasDiversity() +end + -- One word per rung of Telemetry.STATUS. Title case throughout, because -- pageSubtitle() puts these under the full-screen page's own title, where -- shouting would read wrong. The tiers that want a caps banner spell it out @@ -125,6 +138,104 @@ function Display.lqValueText() return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) end +--- Uplink LQ as a bar fraction, 0-100. +function Display.lqPct() + if not Telemetry.isConnected() then + return 0 + end + return Telemetry.link.rqly or 0 +end + +--- Signal headroom as a bar fraction, 0-100. Zero rather than nil so a size +--- closure never has to think about it; the bar hides on hasHeadroom(). +function Display.headroomPct() + return Telemetry.headroomPct or 0 +end + +--- Whether there is a rated floor to draw a headroom scale against. +function Display.hasHeadroom() + return Telemetry.headroomPct ~= nil +end + +--- Downlink link quality, e.g. "100 %". Captioned TQly by its callers, which +--- is the name EdgeTX puts in the model's telemetry list. +function Display.tqlyText() + local tqly = Telemetry.link.tqly + if not Telemetry.isConnected() or tqly == nil then + return "--" + end + return table.concat({ tostring(tqly), " %" }) +end + +--- Downlink RSSI, e.g. "-95". Captioned TRSS by its callers. +function Display.trssText() + local trss = Telemetry.link.trss + if not Telemetry.isConnected() or trss == nil then + return "--" + end + return tostring(trss) +end + +--- The same with its unit, for rows wide enough to carry one. +--- A separate zero-argument function rather than a parameter, because these +--- are handed to LVGL by reference and called with no arguments. +function Display.trssTextUnit() + local trss = Telemetry.link.trss + if not Telemetry.isConnected() or trss == nil then + return "--" + end + return table.concat({ tostring(trss), " dBm" }) +end + +--- The rated floor on its own, e.g. "-108", for the headroom bar's left +--- endpoint. Empty when there is no floor, which is also when the bar hides. +function Display.sensText() + local sens = Telemetry.link.sens + if sens == nil then + return "" + end + return tostring(sens) +end + +--- The headroom bar's right endpoint, the RSSI above which more signal buys +--- nothing. A property of the scale, so it comes from the state, not a literal +--- in a layout file. +function Display.ceilingText() + return tostring(Telemetry.RSSI_CEILING) +end + +--- TX power on its own, e.g. "100 mW", or "--" while unknown. +function Display.powerText() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) +end + +--- Per-cell battery voltage, e.g. "4S 3.75 V", or "--". +--- The pack total is a separate value so a row can carry one or both. +function Display.cellText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + local cells = Telemetry.cellCnt + if cells == nil then + return "--" + end + return string.format("%dS %.2f V", cells, vbat / cells) +end + +--- Pack voltage, e.g. "15.20 V", or "--". +function Display.packText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + return string.format("%.2f V", vbat) +end + --- Link quality as the hero label spells it, e.g. "LQ 100%". function Display.lqText() return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) @@ -221,6 +332,10 @@ function Display.heroColor() return COLOR_THEME_PRIMARY1 end +-- One health ramp, shared by the bars and the status LED, so a green bar and +-- an amber dot can never describe the same link. +local HEALTH = { GREEN, ORANGE, RED } + -- Link margin in dB above the rated floor. At or below MARGIN_CRIT the -- receiver is at the edge of what it can hear; above MARGIN_WARN there is -- room left to fly into. Same thresholds the range percentage used, the right @@ -228,25 +343,146 @@ end local MARGIN_CRIT = 10 local MARGIN_WARN = 30 ---- Map link margin to a warning colour. -local function marginColor(db) - if db <= MARGIN_CRIT then - return RED +--- Colour for a health level: 1 good, 2 warn, 3 critical. +function Display.healthColor(level) + return HEALTH[level] or COLOR_THEME_DISABLED +end + +-- Uplink LQ. Below LQ_CRIT ExpressLRS is dropping enough packets to matter; +-- at or above LQ_GOOD the link is doing what it is supposed to. +local LQ_GOOD = 90 +local LQ_CRIT = 50 + +--- Health level of the uplink LQ. +function Display.lqLevel() + if not Telemetry.isConnected() then + return 3 + end + local lq = Telemetry.link.rqly or 0 + if lq >= LQ_GOOD then + return 1 + end + if lq >= LQ_CRIT then + return 2 end - if db <= MARGIN_WARN then - return ORANGE + return 3 +end + +--- Health level of the link margin, worst while there is no margin to judge. +function Display.marginLevel() + local db = Telemetry.marginDb() + if db == nil then + return 3 + end + if db > MARGIN_WARN then + return 1 + end + if db > MARGIN_CRIT then + return 2 + end + return 3 +end + +--- Fill colour for the LQ bar. +function Display.lqBarColor() + return Display.healthColor(Display.lqLevel()) +end + +--- Fill colour for the headroom bar. +function Display.headroomBarColor() + return Display.healthColor(Display.marginLevel()) +end + +-- Blink periods in getTime() ticks, which run at 10 ms. Halved for the on/off +-- phase, so BLINK_SLOW is 1 Hz and BLINK_FAST is 4 Hz. +local BLINK_SLOW = 50 +local BLINK_FAST = 12 + +--- Whether a blink of the given half-period is in its lit phase. +local function lit(halfPeriod) + return math.floor(getTime() / halfPeriod) % 2 == 0 +end + +--- Status LED colour. +--- Red means bad and blinking means the link is broken or bound to the wrong +--- model; solid means the link works and only the numbers are poor. The +--- reference widgets blink to mean "healthy", which teaches the eye to ignore +--- a blinking dot -- the one thing it must not do here. +--- The off phase returns the track colour rather than hiding the dot, so it +--- reads as an LED that is off instead of a hole in the strip, and stays right +--- under a transparent background. +function Display.ledColor() + local level = Telemetry.statusLevel() + local STATUS = Telemetry.STATUS + if level == STATUS.NO_MODULE then + return COLOR_THEME_DISABLED + end + if level == STATUS.NO_TELEMETRY then + return lit(BLINK_SLOW) and RED or COLOR_THEME_DISABLED + end + if level == STATUS.MISMATCH then + return lit(BLINK_FAST) and RED or COLOR_THEME_DISABLED + end + return Display.lqBarColor() +end + +-- ExpressLRS power ladder in mW. The meter counts steps on this rather than +-- scaling a percentage, because the steps are what the module actually offers +-- and 100 mW is halfway up the ladder but 5% of the range. +local POWER_STEPS = { 10, 25, 50, 100, 250, 500, 1000, 2000 } + +--- How many power steps are lit, 0 to #POWER_STEPS. +function Display.powerSteps() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return 0 + end + local n = 0 + for i = 1, #POWER_STEPS do + if tpwr >= POWER_STEPS[i] then + n = i + end + end + return n +end + +--- Total cells in the power meter, so a layout can size it without knowing +--- the ladder. +function Display.powerStepCount() + return #POWER_STEPS +end + +--- Colour for antenna cell n (1 or 2): lit when that path is the active one. +--- A factory, not a callback -- call it when building. +function Display.antColor(n) + return function() + if not Telemetry.isConnected() then + return COLOR_THEME_DISABLED + end + -- ANT is 0-based and the cells are 1-based, same convention as the + -- full-screen page's "Ant 1" / "Ant 2" rows. + if (Telemetry.link.ant or 0) + 1 == n then + return COLOR_THEME_PRIMARY1 + end + return COLOR_THEME_DISABLED end - return COLOR_THEME_SECONDARY1 end --- Detail line colour: warns as the link margin shrinks, neutral while there --- is no margin to judge -- no link, or a rate with no published floor. +--- Deliberately not the health ramp: a line of text that turns green whenever +--- nothing is wrong is noise, while a bar that turns green is the point of +--- drawing it. function Display.detailColor() local db = Telemetry.marginDb() if db == nil then return COLOR_THEME_SECONDARY1 end - return marginColor(db) + local level = Display.marginLevel() + if level == 1 then + return COLOR_THEME_SECONDARY1 + end + return Display.healthColor(level) end --- Hero label font for one tier of a screen's WidgetUI.fonts table. diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 017bf03..d38130b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -7,11 +7,13 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout +local Components = ctx.Components local WidgetUI = {} -- Breakpoints: absolute pixel values for 800x480. WidgetUI.breakpoints = { + wideW = 560, topBarW = 200, sixthH = 74, quarterH = 104, @@ -214,19 +216,26 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: full telemetry display with title and large fonts. +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +--- 1/1: the uplink panel between a status strip and the group rows. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this picks the hero size the tier can afford. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - }, - } - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) + local m = measured() + Components.fullTier(w, h, opa, m, { + wide = w >= WidgetUI.breakpoints.wideW, + lqFont = MIDSIZE, + lqH = m.mid, + }) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 3b28365..544937f 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -7,11 +7,13 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout +local Components = ctx.Components local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x480 portrait. WidgetUI.breakpoints = { + wideW = 240, topBarW = 80, sixthH = 55, quarterH = 78, @@ -180,19 +182,26 @@ local function appendDataRows(rows) } end ---- 1/1: full telemetry display with title. +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +--- 1/1: the uplink panel between a status strip and the group rows. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this picks the hero size the tier can afford. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - }, - } - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) + local m = measured() + Components.fullTier(w, h, opa, m, { + wide = w >= WidgetUI.breakpoints.wideW, + lqFont = BOLD, + lqH = m.bold, + }) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 45f7adc..d351814 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -7,16 +7,20 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout +local Components = ctx.Components local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x272. +-- wideW is the only width gate past the top bar: below it a zone is half the +-- screen and the 1/1 layout drops to one column of groups. WidgetUI.breakpoints = { topBarW = 100, sixthH = 37, quarterH = 52, thirdH = 73, halfH = 108, + wideW = 340, } WidgetUI.fonts = { @@ -206,19 +210,26 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: full telemetry display with title. +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +--- 1/1: the uplink panel between a status strip and the group rows. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this picks the hero size the tier can afford. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - }, - } - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) + local m = measured() + Components.fullTier(w, h, opa, m, { + wide = w >= WidgetUI.breakpoints.wideW, + lqFont = MIDSIZE, + lqH = m.mid, + }) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 78afdb1..71fdbac 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -7,12 +7,14 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout +local Components = ctx.Components local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x320. -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { + wideW = 340, topBarW = 100, sixthH = 44, quarterH = 62, @@ -215,19 +217,26 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: full telemetry display with title. +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +--- 1/1: the uplink panel between a status strip and the group rows. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this picks the hero size the tier can afford. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - }, - } - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) + local m = measured() + Components.fullTier(w, h, opa, m, { + wide = w >= WidgetUI.breakpoints.wideW, + lqFont = MIDSIZE, + lqH = m.mid, + }) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index fae285c..b6fcf99 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -7,12 +7,14 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity local WidgetLayout = ctx.WidgetLayout +local Components = ctx.Components local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x240. -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { + wideW = 240, topBarW = 80, sixthH = 30, quarterH = 42, @@ -207,19 +209,26 @@ function WidgetUI.buildHalf(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/1: full telemetry display with title. +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +--- 1/1: the uplink panel between a status strip and the group rows. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this picks the hero size the tier can afford. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - }, - } - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) + local m = measured() + Components.fullTier(w, h, opa, m, { + wide = w >= WidgetUI.breakpoints.wideW, + lqFont = BOLD, + lqH = m.bold, + }) end --- Route to the appropriate minimized layout based on widget dimensions. From 84427388c0a4021834713ea5b8599045a24610a5 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Sun, 16 Aug 2026 23:13:54 +0300 Subject: [PATCH 203/218] Take the health colours from the theme, and draw the 1/2 tier GREEN and RED are raw primaries -- RGB(0,255,0) and RGB(255,0,0), colors.cpp:51-54 -- so a full-width LQ bar in GREEN was the loudest thing on the screen, and it stayed that way whatever theme the pilot had chosen. The theme carries the same three meanings in colours picked to sit together: EDIT, ACTIVE and WARNING. Bars, the status LED and the LQ headline now take those and repaint with the theme. Text keeps a separate ramp. COLOR_THEME_ACTIVE is a bright yellow, which is fine as a bar fill and illegible as a word on the light themes' near-white panel, so warnings in text stay ORANGE. Bar tracks are the disabled grey at part opacity. Solid it read as a second bar competing with its own fill; the theme's soft secondary disappeared into the panel entirely, which left the fill a floating stub with nothing to measure it against. The 1/2 tier keeps both bars, which is the point of the layout -- it is the size the widget is usually placed at, and the mirrored-blocks draft this replaced could not keep even one there. The battery row drops before either bar is squeezed below the height at which it stops being a bar. --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 82 ++++++++++++++++++++- src/WIDGETS/ELRSTelemetry/ui/display.lua | 32 ++++++-- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 30 ++++---- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 20 ++--- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 30 ++++---- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 30 ++++---- src/WIDGETS/ELRSTelemetry/ui/small.lua | 30 ++++---- 7 files changed, 176 insertions(+), 78 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index ce6cb4e..e982efd 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -27,6 +27,10 @@ local Display = ... local Components = {} +-- How solid a bar's unfilled track is. Enough to read the bar's full +-- extent, not so much that the empty half draws the eye before the fill. +local TRACK_OPACITY = 90 + -- ============================================================================ -- Metrics -- ============================================================================ @@ -133,7 +137,13 @@ function Components.bar(dst, rect, y, spec) y = y, w = w, h = h, + -- A track has to be soft enough not to compete with the fill and solid + -- enough to show how far the fill has to go -- an invisible track makes + -- the bar a floating stub with no scale. COLOR_THEME_SECONDARY2 vanishes + -- into the panel and COLOR_THEME_DISABLED reads as a second bar, so it is + -- the disabled grey at part opacity. color = COLOR_THEME_DISABLED, + opacity = TRACK_OPACITY, filled = true, } dst[#dst + 1] = { @@ -194,6 +204,7 @@ function Components.segments(dst, rect, y, spec) w = total, h = h, color = COLOR_THEME_DISABLED, + opacity = TRACK_OPACITY, filled = true, } dst[#dst + 1] = { @@ -313,10 +324,22 @@ function Components.statusStrip(dst, rect, y, m, spec) -- The power meter fills the gap between the two only where there is one. -- Measured against the widest rate name the tables carry, so a switch from -- 25Hz to K1000Full never pushes the meter sideways. + local modeW = Components.textWidth("K1000Full", SMLSIZE) + local powerX = textX + modeW + m.pad * 3 if spec.power then - local modeW = Components.textWidth("K1000Full", SMLSIZE) - local powerX = textX + modeW + m.pad * 3 Components.powerGroup(dst, { x = powerX, w = antX - powerX - m.pad * 3 }, y, m) + elseif spec.powerText then + -- No room for the meter, so the reading itself takes the gap. The meter + -- is the first thing to go and the number is the last, because "50 mW" + -- still answers the question a lit cell count only illustrates. + Components.label(dst, { + x = powerX, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.powerText, + visible = Display.isNotMismatch, + }) end return y + h end @@ -370,7 +393,7 @@ function Components.uplinkPanel(dst, rect, y, m, spec) x = rect.x, y = y, font = spec.lqFont, - color = Display.lqBarColor, + color = Display.lqTextColor, text = Display.lqText, }) y = y + spec.lqH @@ -633,6 +656,59 @@ function Components.fullTier(w, h, opa, m, spec) lvgl.build(root) end +--- The 1/2 tier: the same panel with the group rows and the rules taken out. +--- Both bars survive here, and that is the point of the whole layout. This is +--- the size the widget is most often placed at, and it is where an earlier +--- draft -- uplink and downlink as mirrored blocks -- could not keep even one +--- of them. The downlink row goes first because it is a whole group, and the +--- bars are what the widget is for. +--- TX power stays as the text in the strip; only the meter goes. +function Components.halfTier(w, h, opa, m, spec) + local pad = m.pad + local inner = { x = pad, w = w - pad * 2 } + local battery = { + header = "BATTERY", + values = { { text = Display.cellText, sample = "4S 3.75 V" } }, + } + local AIR_GAPS = 2 + -- Thinner than this and a bar is a line, not a meter. + local MIN_BAR = 3 + local fixed = pad * 2 + + (m.sml + 4) -- status strip + + spec.lqH + + m.gap + + m.sml -- RSSI row + + -- Battery is a whole group, so it goes before either bar is squeezed below + -- the height at which it stops reading as a bar. That is the degradation + -- order the whole layout follows: lose a row, keep the instruments. + local showBattery = Components.groupWidth(m, battery) <= inner.w and (h - fixed - m.sml) >= MIN_BAR * 2 + if showBattery then + fixed = fixed + m.sml + end + + local slack = h - fixed + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.35), 20)) + local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) + + local root = {} + local panel = Components.panel(root, { x = 0, y = 0, w = w, h = h }, { opacity = opa }) + + local y = Components.statusStrip(panel, inner, pad, m, { powerText = true }) + y = Components.uplinkPanel(panel, inner, y + air, m, { + lqFont = spec.lqFont, + lqH = spec.lqH, + barH = barH, + lqBar = true, + headroomBar = true, + }) + if showBattery then + Components.groupRow(panel, inner, y + air, m, { battery }) + end + + lvgl.build(root) +end + -- ============================================================================ -- Return components -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index 023b8a2..23102df 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -334,7 +334,19 @@ end -- One health ramp, shared by the bars and the status LED, so a green bar and -- an amber dot can never describe the same link. -local HEALTH = { GREEN, ORANGE, RED } +-- +-- Theme colours rather than the GREEN/ORANGE/RED literals. Those are raw +-- primaries -- GREEN is RGB(0,255,0) and RED is RGB(255,0,0) +-- (colors.cpp:51-54) -- which is why a full-width LQ bar in GREEN was the +-- loudest thing on the screen, and they stay that way whatever theme the +-- pilot picked. The theme's own EDIT/ACTIVE/WARNING are the same three +-- meanings in colours chosen to sit together, and they repaint with the theme. +local HEALTH = { COLOR_THEME_EDIT, COLOR_THEME_ACTIVE, COLOR_THEME_WARNING } + +-- The same ramp for text. COLOR_THEME_ACTIVE is a bright yellow: fine as a +-- bar fill, illegible as a word on the light themes' near-white panel, so +-- warnings in text keep ORANGE. +local HEALTH_TEXT = { COLOR_THEME_EDIT, ORANGE, COLOR_THEME_WARNING } -- Link margin in dB above the rated floor. At or below MARGIN_CRIT the -- receiver is at the edge of what it can hear; above MARGIN_WARN there is @@ -343,11 +355,16 @@ local HEALTH = { GREEN, ORANGE, RED } local MARGIN_CRIT = 10 local MARGIN_WARN = 30 ---- Colour for a health level: 1 good, 2 warn, 3 critical. +--- Fill colour for a health level: 1 good, 2 warn, 3 critical. function Display.healthColor(level) return HEALTH[level] or COLOR_THEME_DISABLED end +--- The same, for text that has to stay readable on the panel. +function Display.healthTextColor(level) + return HEALTH_TEXT[level] or COLOR_THEME_DISABLED +end + -- Uplink LQ. Below LQ_CRIT ExpressLRS is dropping enough packets to matter; -- at or above LQ_GOOD the link is doing what it is supposed to. local LQ_GOOD = 90 @@ -388,6 +405,11 @@ function Display.lqBarColor() return Display.healthColor(Display.lqLevel()) end +--- Colour for the LQ headline, which is a word and not a fill. +function Display.lqTextColor() + return Display.healthTextColor(Display.lqLevel()) +end + --- Fill colour for the headroom bar. function Display.headroomBarColor() return Display.healthColor(Display.marginLevel()) @@ -418,10 +440,10 @@ function Display.ledColor() return COLOR_THEME_DISABLED end if level == STATUS.NO_TELEMETRY then - return lit(BLINK_SLOW) and RED or COLOR_THEME_DISABLED + return lit(BLINK_SLOW) and COLOR_THEME_WARNING or COLOR_THEME_DISABLED end if level == STATUS.MISMATCH then - return lit(BLINK_FAST) and RED or COLOR_THEME_DISABLED + return lit(BLINK_FAST) and COLOR_THEME_WARNING or COLOR_THEME_DISABLED end return Display.lqBarColor() end @@ -482,7 +504,7 @@ function Display.detailColor() if level == 1 then return COLOR_THEME_SECONDARY1 end - return Display.healthColor(level) + return Display.healthTextColor(level) end --- Hero label font for one tier of a screen's WidgetUI.fonts table. diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index d38130b..9da112b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -11,6 +11,16 @@ local Components = ctx.Components local WidgetUI = {} +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + -- Breakpoints: absolute pixel values for 800x480. WidgetUI.breakpoints = { wideW = 560, @@ -208,22 +218,12 @@ local function appendDataRows(rows) } end ---- 1/2: the four data rows without the title. ---- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +--- 1/2: the panel without the group rows or the rules. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) - local rows = {} - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) -end - --- Font line heights, measured once. Fonts do not change under the widget, so --- there is nothing to invalidate; this only avoids re-measuring per build. -local metrics -local function measured() - if not metrics then - metrics = Components.measure() - end - return metrics + local m = measured() + Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) end --- 1/1: the uplink panel between a status strip and the group rows. diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 544937f..2c8a019 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -11,6 +11,16 @@ local Components = ctx.Components local WidgetUI = {} +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + -- Breakpoints: absolute pixel values for 320x480 portrait. WidgetUI.breakpoints = { wideW = 240, @@ -182,16 +192,6 @@ local function appendDataRows(rows) } end --- Font line heights, measured once. Fonts do not change under the widget, so --- there is nothing to invalidate; this only avoids re-measuring per build. -local metrics -local function measured() - if not metrics then - metrics = Components.measure() - end - return metrics -end - --- 1/1: the uplink panel between a status strip and the group rows. --- Everything but the fonts is shared, so the composition itself lives in --- ui/components.lua and this picks the hero size the tier can afford. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index d351814..7ff24e9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -11,6 +11,16 @@ local Components = ctx.Components local WidgetUI = {} +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + -- Breakpoints: absolute pixel values for 480x272. -- wideW is the only width gate past the top bar: below it a zone is half the -- screen and the 1/1 layout drops to one column of groups. @@ -202,22 +212,12 @@ local function appendDataRows(rows) } end ---- 1/2: the four data rows without the title. ---- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +--- 1/2: the panel without the group rows or the rules. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) - local rows = {} - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) -end - --- Font line heights, measured once. Fonts do not change under the widget, so --- there is nothing to invalidate; this only avoids re-measuring per build. -local metrics -local function measured() - if not metrics then - metrics = Components.measure() - end - return metrics + local m = measured() + Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) end --- 1/1: the uplink panel between a status strip and the group rows. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 71fdbac..ecbcd73 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -11,6 +11,16 @@ local Components = ctx.Components local WidgetUI = {} +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + -- Breakpoints: absolute pixel values for 480x320. -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { @@ -209,22 +219,12 @@ local function appendDataRows(rows) } end ---- 1/2: the four data rows without the title. ---- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +--- 1/2: the panel without the group rows or the rules. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) - local rows = {} - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) -end - --- Font line heights, measured once. Fonts do not change under the widget, so --- there is nothing to invalidate; this only avoids re-measuring per build. -local metrics -local function measured() - if not metrics then - metrics = Components.measure() - end - return metrics + local m = measured() + Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) end --- 1/1: the uplink panel between a status strip and the group rows. diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index b6fcf99..5d1a665 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -11,6 +11,16 @@ local Components = ctx.Components local WidgetUI = {} +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + -- Breakpoints: absolute pixel values for 320x240. -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { @@ -201,22 +211,12 @@ local function appendDataRows(rows) } end ---- 1/2: the four data rows without the title. ---- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom. +--- 1/2: the panel without the group rows or the rules. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) - local rows = {} - appendDataRows(rows) - WidgetLayout.column(w, h, opa, rows) -end - --- Font line heights, measured once. Fonts do not change under the widget, so --- there is nothing to invalidate; this only avoids re-measuring per build. -local metrics -local function measured() - if not metrics then - metrics = Components.measure() - end - return metrics + local m = measured() + Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) end --- 1/1: the uplink panel between a status strip and the group rows. From 18fdd7629561ffcf5bcf4533892a7f3ca68f1132 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 09:08:22 +0300 Subject: [PATCH 204/218] Keep dBm on the downlink RSSI at every width TQly sits beside TRSS on the same row in percent, so a bare -94 there invites reading it as one too. The unit is not something the width ladder gets to trade away. It was the first thing to go, which left the narrowest rung showing "TRSS -94". Now the pack total goes first, then the row splits, and the DOWNLINK header is what drops last -- the right order, because TQly and TRSS name themselves and a bare number does not name its unit. Costs nothing at the sizes that matter: the 396px zone still fits the row under its header, and the 198px one reaches the headerless rung it was already using. --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 27 +++++++++++---------- src/WIDGETS/ELRSTelemetry/ui/display.lua | 15 +++--------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index e982efd..595e697 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -543,16 +543,17 @@ end -- The 1/1 tier -- ============================================================================ -local function downlinkGroup(compact, headed) +--- The downlink pair. TRSS always carries dBm: a bare -94 beside a +--- percentage invites reading it as one, and the two numbers on this row are +--- in different units. The unit is not something the width ladder may trade +--- away, so what gives instead is the pack total, then the row, then the +--- header. +local function downlinkGroup(headed) return { header = headed and "DOWNLINK" or "", values = { { caption = "TQly", text = Display.tqlyText, sample = "100 %" }, - { - caption = "TRSS", - text = compact and Display.trssText or Display.trssTextUnit, - sample = compact and "-105" or "-105 dBm", - }, + { caption = "TRSS", text = Display.trssText, sample = "-105 dBm" }, }, } end @@ -588,16 +589,16 @@ local function groupRows(w, m) return total <= w end - if fits(downlinkGroup(false, true), batteryGroup(false)) then - return { { downlinkGroup(false, true), batteryGroup(false) } } + if fits(downlinkGroup(true), batteryGroup(false)) then + return { { downlinkGroup(true), batteryGroup(false) } } end - if fits(downlinkGroup(true, true), batteryGroup(true)) then - return { { downlinkGroup(true, true), batteryGroup(true) } } + if fits(downlinkGroup(true), batteryGroup(true)) then + return { { downlinkGroup(true), batteryGroup(true) } } end - if fits(downlinkGroup(true, true)) then - return { { downlinkGroup(true, true) }, { batteryGroup(true) } } + if fits(downlinkGroup(true)) then + return { { downlinkGroup(true) }, { batteryGroup(true) } } end - return { { downlinkGroup(true, false) }, { batteryGroup(true) } } + return { { downlinkGroup(false) }, { batteryGroup(true) } } end --- The whole 1/1 tier: status strip, uplink panel, group rows. diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index 23102df..bf4e830 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -167,19 +167,10 @@ function Display.tqlyText() return table.concat({ tostring(tqly), " %" }) end ---- Downlink RSSI, e.g. "-95". Captioned TRSS by its callers. +--- Downlink RSSI, e.g. "-95 dBm". Captioned TRSS by its callers. +--- The unit is not optional: TQly sits beside this on the same row in +--- percent, and a bare -95 next to a percentage invites reading it as one. function Display.trssText() - local trss = Telemetry.link.trss - if not Telemetry.isConnected() or trss == nil then - return "--" - end - return tostring(trss) -end - ---- The same with its unit, for rows wide enough to carry one. ---- A separate zero-argument function rather than a parameter, because these ---- are handed to LVGL by reference and called with no arguments. -function Display.trssTextUnit() local trss = Telemetry.link.trss if not Telemetry.isConnected() or trss == nil then return "--" From ee99d2219eade97085884533e74a84732f9b125d Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 09:12:11 +0300 Subject: [PATCH 205/218] Paint the power meter and antenna cells in the theme accent Both were COLOR_THEME_PRIMARY1, which is the text colour -- RGB(0,0,0) on the light themes. As a glyph that is correct and as a filled block it is a black bar dropped onto a blue panel, which is what they looked like. COLOR_THEME_FOCUS is what EdgeTX uses everywhere else to mean "this one is active", which is exactly what a lit power cell and the live antenna cell are saying, and it follows the theme. Power deliberately stays off the green/amber/red ramp: 500 mW is not worse than 50, so a health colour there would be claiming something the number does not mean. The mismatch banners move from RED to COLOR_THEME_WARNING with them, so the whole widget now draws from the theme. The single exception is ORANGE for warning text, because COLOR_THEME_ACTIVE is a yellow that cannot be read on a near-white panel. --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 8 ++++++-- src/WIDGETS/ELRSTelemetry/ui/display.lua | 8 ++++++-- src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua | 2 +- src/WIDGETS/ELRSTelemetry/ui/topbar.lua | 2 +- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index 595e697..958dfa9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -308,7 +308,7 @@ function Components.statusStrip(dst, rect, y, m, spec) x = textX, y = y, font = BOLD, - color = RED, + color = COLOR_THEME_WARNING, -- A constant label plus a bool closure, not a formatter: statusText() -- keeps title case for the full-screen subtitle it also feeds, and a -- string closure would be hashed every frame to say the same thing. @@ -360,7 +360,11 @@ function Components.powerGroup(dst, rect, y, m) Components.segments(dst, { x = rect.x + headW, w = meterW }, y + 2, { count = Display.powerStepCount(), h = m.sml - 4, - color = COLOR_THEME_PRIMARY1, + -- Accent, not COLOR_THEME_PRIMARY1. The lit cells are a filled area, and + -- the text colour used as a fill is a black bar sitting on a blue panel. + -- Power is also not a health reading -- 500 mW is not worse than 50 -- so + -- it deliberately stays off the green/amber/red ramp. + color = COLOR_THEME_FOCUS, steps = Display.powerSteps, }) Components.label(dst, { diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index bf4e830..611c3fe 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -318,7 +318,7 @@ end --- Hero label colour: red only while a connected link reports a mismatch. function Display.heroColor() if Telemetry.isMismatch() then - return RED + return COLOR_THEME_WARNING end return COLOR_THEME_PRIMARY1 end @@ -475,7 +475,11 @@ function Display.antColor(n) -- ANT is 0-based and the cells are 1-based, same convention as the -- full-screen page's "Ant 1" / "Ant 2" rows. if (Telemetry.link.ant or 0) + 1 == n then - return COLOR_THEME_PRIMARY1 + -- The theme's accent, not COLOR_THEME_PRIMARY1: that is the text colour, + -- RGB(0,0,0) on the light themes, and a filled black block is not text. + -- This cell means "the link is on this path", which is what the accent + -- says everywhere else in EdgeTX. + return COLOR_THEME_FOCUS end return COLOR_THEME_DISABLED end diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua index 463e9ec..67935d3 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -134,7 +134,7 @@ function FullScreenUI.build() { type = lvgl.LABEL, font = BOLD, - color = RED, + color = COLOR_THEME_WARNING, text = "Model Mismatch — RC commands not sent", visible = Display.isMismatch, }, diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua index 009e90c..52ad92f 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -18,7 +18,7 @@ local TopBarUI = {} --- Display.heroColor uses PRIMARY1. local function mismatchColor() if Display.isMismatch() then - return RED + return COLOR_THEME_WARNING end return COLOR_THEME_PRIMARY2 end From 4e47f52a7eb8ea9ec58e2713ec6f4f7158ae41b2 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 09:28:47 +0300 Subject: [PATCH 206/218] Tighten the strip: smaller LED, inset panel, power above the uplink Four adjustments from looking at it on the radio rather than in the plan. The status LED was centred on the strip height, which is the text's line box plus padding, so it sat below the RF mode beside it -- near enough to read as a mistake rather than a choice. It now centres on the line box, landing on the same centre as the antenna cells, and it is a third smaller: at half the row height an indicator stops being a dot and starts competing with the text. It also gets a fixed lane, so its radius can be tuned without the RF mode moving sideways. TX POWER's meter was absorbing the whole gap between the RF mode and the antenna cells, which dragged its own reading to the far side of the strip with nothing tying the two together. The meter now has a natural width measured off the row, the reading sits a fixed gap after it, and the slack stays to their right. Whether the meter fits at all is now measured against the antenna cells' position instead of assumed from the width gate. The panel is inset from the zone rather than filling it, so two widgets side by side no longer share one edge, and the content is inset again from the panel's own border -- full-width bars starting and ending on the border read as spilling out of the panel. And at half width TX POWER moves above the uplink panel instead of below the group rows. It is link configuration, like the RF mode and the antenna it now sits with; last place filed it under the flight controller's readings, which is not what it is. --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 104 +++++++++++++++----- 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index 958dfa9..a209221 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -58,6 +58,27 @@ function Components.textWidth(s, font) return (lcd.sizeText(s, font)) end +--- Split a widget zone into the panel rect and the content rect inside it. +--- The panel is inset from the zone rather than filling it, so two widgets +--- placed side by side do not end up sharing one edge, and the content is +--- inset again from the panel's own border rather than sitting against it -- +--- full-width bars in particular read as spilling out of the panel when they +--- start and end on its border. +--- Both insets come off the theme's own padding, so they scale with the screen +--- instead of being 6px on a 480 and 6px on an 800. +--- panel is absolute; inner is relative to the panel, because lvgl positions +--- children against their parent's origin. +function Components.frame(w, h, m) + local margin = m.gap + local pad = m.pad + 2 + return { + panel = { x = margin, y = margin, w = w - margin * 2, h = h - margin * 2 }, + inner = { x = pad, w = w - margin * 2 - pad * 2 }, + pad = pad, + h = h - margin * 2, + } +end + -- ============================================================================ -- Elements -- ============================================================================ @@ -287,14 +308,24 @@ end --- teaches the eye to ignore the banner that matters. function Components.statusStrip(dst, rect, y, m, spec) local h = m.sml + 4 - local r = math.floor(m.sml / 2) - 1 + -- The LED gets a fixed lane sized off the row, and sits centred in it. The + -- lane is what the text after it measures from, so the dot's radius can be + -- tuned without the RF mode shifting sideways. + local lane = math.floor(m.sml / 2) + 2 + -- Deliberately smaller than the lane. An indicator only has to be seen, and + -- at half the row height it stops reading as a dot and starts competing with + -- the text it sits beside. + local r = math.floor(m.sml / 4) + 1 Components.led(dst, { - x = rect.x + r, - y = y + math.floor(h / 2), + x = rect.x + math.floor(lane / 2), + -- Centred on the text's line box, not on the strip. The strip is the line + -- box plus padding, so centring in it drops the dot below the RF mode it + -- sits beside -- close enough to look like a mistake rather than a choice. + y = y + math.floor(m.sml / 2), radius = r, color = Display.ledColor, }) - local textX = rect.x + 2 * r + m.pad + local textX = rect.x + lane + m.pad Components.label(dst, { x = textX, @@ -321,14 +352,16 @@ function Components.statusStrip(dst, rect, y, m, spec) local antX = rect.x + rect.w - antW Components.antenna(dst, { x = antX }, y, m) - -- The power meter fills the gap between the two only where there is one. - -- Measured against the widest rate name the tables carry, so a switch from - -- 25Hz to K1000Full never pushes the meter sideways. + -- The meter goes in the gap between the RF mode and the antenna cells, at + -- its natural width and only when it genuinely fits there. Its x is measured + -- against the widest rate name the tables carry, so switching from 25Hz to + -- K1000Full never pushes it sideways. local modeW = Components.textWidth("K1000Full", SMLSIZE) local powerX = textX + modeW + m.pad * 3 - if spec.power then - Components.powerGroup(dst, { x = powerX, w = antX - powerX - m.pad * 3 }, y, m) - elseif spec.powerText then + local meterFits = powerX + Components.powerGroupWidth(m) <= antX + if spec.power and meterFits then + Components.powerGroup(dst, { x = powerX }, y, m) + elseif spec.power or spec.powerText then -- No room for the meter, so the reading itself takes the gap. The meter -- is the first thing to go and the number is the last, because "50 mW" -- still answers the question a lit cell count only illustrates. @@ -344,11 +377,25 @@ function Components.statusStrip(dst, rect, y, m, spec) return y + h end +--- Width of the TX POWER group, so a caller can place it without stretching +--- it. The meter is sized off the row height rather than off the space +--- available: a meter that grows to fill its container drags its own reading +--- away from it, which leaves "50 mW" stranded at the far side of the strip +--- with no visible tie to the cells it belongs to. +function Components.powerGroupWidth(m) + return Components.textWidth("TX POWER ", SMLSIZE) + + Display.powerStepCount() * math.floor(m.sml / 2) + + m.pad + + Components.textWidth("2000 mW", SMLSIZE) +end + --- TX POWER as an inline group: header, stepped meter, value. +--- Laid out left to right at its natural width, so the reading sits right +--- after the cells it describes. Any slack in the strip stays to its right. function Components.powerGroup(dst, rect, y, m) local headW = Components.textWidth("TX POWER ", SMLSIZE) local valW = Components.textWidth("2000 mW", SMLSIZE) - local meterW = rect.w - headW - valW - m.pad + local meterW = Display.powerStepCount() * math.floor(m.sml / 2) Components.label(dst, { x = rect.x, y = y, @@ -371,7 +418,10 @@ function Components.powerGroup(dst, rect, y, m) x = rect.x + headW + meterW + m.pad, y = y, w = valW, - align = RIGHT, + -- Left, so the reading starts a fixed gap from the meter. Right-aligning + -- it in a box wide enough for "2000 mW" would push "50 mW" away from the + -- cells again, which is the thing being fixed. + align = LEFT, font = SMLSIZE, text = Display.powerText, visible = Display.isNotMismatch, @@ -615,8 +665,8 @@ end --- "looks basic" means. function Components.fullTier(w, h, opa, m, spec) local wide = spec.wide - local pad = m.pad - local inner = { x = pad, w = w - pad * 2 } + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner local rows = groupRows(inner.w, m) -- Everything except the two bars and the air between blocks. This has to @@ -632,14 +682,22 @@ function Components.fullTier(w, h, opa, m, spec) + (wide and m.sml or 0) -- headroom endpoints + m.sml * #rows + (wide and 0 or m.sml + m.gap) -- narrow puts TX POWER on its own row - local slack = h - fixed + local slack = f.h - fixed local barH = math.max(4, math.min(math.floor(slack * 0.3), 26)) local air = math.max(m.gap, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, { x = 0, y = 0, w = w, h = h }, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.statusStrip(panel, inner, pad, m, { power = wide }) + if not wide then + -- Half width cannot fit the meter in the strip, so it takes its own row + -- directly under it -- above the uplink panel, not below the group rows. + -- TX power is link configuration, like the RF mode and the antenna it now + -- sits with; putting it last filed it under the flight controller's + -- readings, which is not what it is. + y = Components.powerGroup(panel, inner, y + m.gap, m) + end y = Components.rule(panel, inner, y + air) y = Components.uplinkPanel(panel, inner, y + air, m, { lqFont = spec.lqFont, @@ -653,10 +711,6 @@ function Components.fullTier(w, h, opa, m, spec) for i = 1, #rows do y = Components.groupRow(panel, inner, y, m, rows[i]) end - if not wide then - -- No room for the meter in a half-width strip, so it gets the last row. - Components.powerGroup(panel, inner, y + m.gap, m) - end lvgl.build(root) end @@ -669,8 +723,8 @@ end --- bars are what the widget is for. --- TX power stays as the text in the strip; only the meter goes. function Components.halfTier(w, h, opa, m, spec) - local pad = m.pad - local inner = { x = pad, w = w - pad * 2 } + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner local battery = { header = "BATTERY", values = { { text = Display.cellText, sample = "4S 3.75 V" } }, @@ -687,17 +741,17 @@ function Components.halfTier(w, h, opa, m, spec) -- Battery is a whole group, so it goes before either bar is squeezed below -- the height at which it stops reading as a bar. That is the degradation -- order the whole layout follows: lose a row, keep the instruments. - local showBattery = Components.groupWidth(m, battery) <= inner.w and (h - fixed - m.sml) >= MIN_BAR * 2 + local showBattery = Components.groupWidth(m, battery) <= inner.w and (f.h - fixed - m.sml) >= MIN_BAR * 2 if showBattery then fixed = fixed + m.sml end - local slack = h - fixed + local slack = f.h - fixed local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.35), 20)) local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, { x = 0, y = 0, w = w, h = h }, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.statusStrip(panel, inner, pad, m, { powerText = true }) y = Components.uplinkPanel(panel, inner, y + air, m, { From 5fa30b121dc290c30a98fbe8560fadf2ff435453 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 11:05:38 +0300 Subject: [PATCH 207/218] update --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 77 +++-- src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 99 ++++++- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 284 ++++++++++--------- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 159 +++-------- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 298 ++++++++++---------- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 282 +++++++++--------- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 231 ++++++--------- 7 files changed, 715 insertions(+), 715 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index a209221..cb0b846 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -59,23 +59,23 @@ function Components.textWidth(s, font) end --- Split a widget zone into the panel rect and the content rect inside it. ---- The panel is inset from the zone rather than filling it, so two widgets ---- placed side by side do not end up sharing one edge, and the content is ---- inset again from the panel's own border rather than sitting against it -- ---- full-width bars in particular read as spilling out of the panel when they ---- start and end on its border. ---- Both insets come off the theme's own padding, so they scale with the screen ---- instead of being 6px on a 480 and 6px on an 800. +--- The panel fills the zone and the breathing room is padding inside it. A +--- widget that inset its panel instead would sit a couple of pixels in from +--- every neighbour on the screen, and the gap reads as a misalignment rather +--- than as space -- the zone boundaries are the screen's grid, and they are not +--- this widget's to redraw. +--- The padding is lvgl.PAD_SMALL because that is the borderPad the ELRS VTX +--- Admin widget's container uses (ELRSVTXAdmin/ui/display.lua:19-44), so the +--- two stack with their text on one left edge. It also comes off the theme's +--- own scale rather than being 4px on a 480 and 4px on an 800. --- panel is absolute; inner is relative to the panel, because lvgl positions --- children against their parent's origin. function Components.frame(w, h, m) - local margin = m.gap - local pad = m.pad + 2 return { - panel = { x = margin, y = margin, w = w - margin * 2, h = h - margin * 2 }, - inner = { x = pad, w = w - margin * 2 - pad * 2 }, - pad = pad, - h = h - margin * 2, + panel = { x = 0, y = 0, w = w, h = h }, + inner = { x = m.pad, w = w - m.pad * 2 }, + pad = m.pad, + h = h, } end @@ -85,7 +85,14 @@ end --- Background panel. Returns the child list to compose into, so everything --- placed afterwards is positioned relative to the panel's own origin. ---- Two rectangles because a rectangle is filled or bordered, never both. +--- A fill and a separate container, the same shape the VTX Admin widget's +--- WidgetLayout uses, and for the same two reasons: opacity applies to the +--- object rather than only its background, so the content cannot live inside +--- the translucent rectangle, and a borderless container puts the content +--- origin exactly on the zone corner. An outlined panel was tried and reads as +--- a card glued over the background while every widget beside it is painted on +--- -- and a border also eats a pixel off each side of the content box, which is +--- enough to make symmetric padding come out lopsided. function Components.panel(dst, rect, spec) local children = {} dst[#dst + 1] = { @@ -104,8 +111,7 @@ function Components.panel(dst, rect, spec) y = rect.y, w = rect.w, h = rect.h, - color = COLOR_THEME_SECONDARY2, - thickness = 1, + thickness = 0, children = children, } return children @@ -115,9 +121,8 @@ end --- A filled rectangle, not an hline: hline has no thickness property, an --- unknown key raises, and lvgl.build() swallows the error along with the --- whole tree. ---- COLOR_THEME_SECONDARY2 is the obvious choice and the wrong one -- it is ---- what the panel edge uses, and against COLOR_THEME_PRIMARY2 at 1px it ---- simply does not appear. +--- COLOR_THEME_SECONDARY2 is the obvious choice for a divider and the wrong +--- one: against COLOR_THEME_PRIMARY2, at 1px, it simply does not appear. function Components.rule(dst, rect, y) dst[#dst + 1] = { type = lvgl.RECTANGLE, @@ -362,12 +367,21 @@ function Components.statusStrip(dst, rect, y, m, spec) if spec.power and meterFits then Components.powerGroup(dst, { x = powerX }, y, m) elseif spec.power or spec.powerText then - -- No room for the meter, so the reading itself takes the gap. The meter - -- is the first thing to go and the number is the last, because "50 mW" - -- still answers the question a lit cell count only illustrates. + -- No room for the meter, so the reading itself takes the gap. The meter is + -- the first thing to go and the number is the last, because "50 mW" still + -- answers the question a lit cell count only illustrates. + -- Anchored to the antenna cells, not left-aligned off the RF mode. Left + -- aligned it lands wherever the reserved width of "K1000Full" happens to + -- end, which on a half-width strip is hard against the cells -- and + -- "50 mW ANT" reads as one phrase. From the right the gap is the same at + -- every width, and the box still starts no earlier than the RF mode allows. + local right = antX - m.pad * 2 + local px = math.max(rect.x, math.min(powerX, right - Components.textWidth("2000 mW", SMLSIZE))) Components.label(dst, { - x = powerX, + x = px, y = y, + w = math.max(1, right - px), + align = RIGHT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = Display.powerText, @@ -690,14 +704,6 @@ function Components.fullTier(w, h, opa, m, spec) local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.statusStrip(panel, inner, pad, m, { power = wide }) - if not wide then - -- Half width cannot fit the meter in the strip, so it takes its own row - -- directly under it -- above the uplink panel, not below the group rows. - -- TX power is link configuration, like the RF mode and the antenna it now - -- sits with; putting it last filed it under the flight controller's - -- readings, which is not what it is. - y = Components.powerGroup(panel, inner, y + m.gap, m) - end y = Components.rule(panel, inner, y + air) y = Components.uplinkPanel(panel, inner, y + air, m, { lqFont = spec.lqFont, @@ -708,6 +714,15 @@ function Components.fullTier(w, h, opa, m, spec) endpoints = wide, }) y = Components.rule(panel, inner, y + air) + air + if not wide then + -- Half width has no gap in the strip wide enough for the meter, so TX power + -- takes its own row at the head of the captioned groups. Header, meter, + -- reading is the same shape DOWNLINK and BATTERY have, so it reads as one of + -- them rather than as something wedged under the strip -- and it leads them + -- because it is what the module is transmitting at, where the rows below it + -- are what came back from the aircraft. + y = Components.powerGroup(panel, inner, y, m) + m.gap + end for i = 1, #rows do y = Components.groupRow(panel, inner, y, m, rows[i]) end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index 4bfa7e4..cf0b7eb 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -16,7 +16,11 @@ local VTXAdmin, PresetsStorage = ... local WidgetLayout = {} -function WidgetLayout.column(w, h, opa, children) +--- pad overrides the container's border padding, for a tier where the default +--- costs more height than it has. Pass a table to keep the horizontal padding +--- while trimming the vertical: the left edge is what a widget in the zone +--- above or beside lines its own text up against, so it is not the one to give. +function WidgetLayout.column(w, h, opa, children, pad) lvgl.build({ { type = lvgl.RECTANGLE, @@ -37,7 +41,7 @@ function WidgetLayout.column(w, h, opa, children) align = LEFT, flexFlow = lvgl.FLOW_COLUMN, flexPad = 0, - borderPad = lvgl.PAD_SMALL, + borderPad = pad or lvgl.PAD_SMALL, children = children, }, }) @@ -173,6 +177,15 @@ function VTXDisplay.detailLong() return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) end +--- Whether detailLong() has anything to say. On the tiers that give it a column +--- row of its own an empty label still costs a full line, and that line is the +--- gap that opens between the band and the cheatsheet on a VTX reporting no +--- power. A flex column drops hidden children rather than reserving their space, +--- so this closes the gap instead of merely blanking it. +function VTXDisplay.hasDetail() + return VTXAdmin.isDisabled() or VTXAdmin.hasPower() +end + function VTXDisplay.mainColor() if VTXAdmin.state.pitmode then return RED @@ -230,6 +243,88 @@ function VTXDisplay.buildCheatsheet(font) return cheatsheetRow(labels) end +--- The headline row: the widget's name, then the band and channel, on one line at +--- one size. For the tiers with no row to spare for a title of its own. +--- Both at one size on purpose. The name is a caption in the muted theme colour +--- and the reading is the primary, which is what lets them share a line without +--- competing -- where two type sizes in one breath read as two importances, and +--- a name shrunk beside its own value reads as an afterthought. +--- extras are trailing labels, for a tier with nowhere else to put them. +function VTXDisplay.buildHeadline(w, font, extras) + local children = { + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + } + for i = 1, extras and #extras or 0 do + children[#children + 1] = extras[i] + end + return { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + -- Wider than the tier's other gaps. At one size and with only colour telling + -- the caption from the reading, PAD_TINY leaves "VTX Admin" and "R1" reading + -- as one run-together word. + flexPad = lvgl.PAD_MEDIUM, + borderPad = 0, + children = children, + } +end + +--- The rows 1/2 and 1/1 share: the name, the band and channel as a hero on its +--- own line, and the detail line. The caller appends its cheatsheet rows. +--- detailText overrides the detail formatter, for a screen too narrow for the +--- full one. Its emptiness follows hasDetail either way, since every form of the +--- line falls silent on the same two conditions. +function VTXDisplay.buildHeroRows(fonts, detailText) + return { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = fonts.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = fonts.detail, + color = COLOR_THEME_SECONDARY1, + text = detailText or VTXDisplay.detailLong, + visible = VTXDisplay.hasDetail, + }, + } +end + --- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows. function VTXDisplay.buildCheatsheetRows(font) local labels = VTXDisplay.build6posLabels(font) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index dbf8cf1..e14f15b 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -17,17 +17,30 @@ local WidgetUI = {} WidgetUI.breakpoints = { topBarW = 200, sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + -- The 1/6 tier stacks the cheatsheet on a second row from here up, + -- and only where the six columns have the width for it. + sixthStackH = 64, + sixthStackW = 300, quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) + -- From here up a 1/3 zone spans the screen rather than half of it: the whole + -- cheatsheet goes on one row, and the type steps up to the size that width + -- affords. + thirdWideW = 560, } WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, third = { status = MIDSIZE, cheatsheet = STDSIZE }, + -- Two lines at the half-width sizes fill the top half of a full-width zone + -- and leave the rest of the panel empty. The band is already as large as the + -- 1/2 tier's, so it is the cheatsheet that steps up -- and this is the one + -- tier where six presets at that size still have the width to sit on one row. + thirdWide = { status = MIDSIZE, cheatsheet = MIDSIZE }, half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = MIDSIZE, detail = STDSIZE, cheatsheet = STDSIZE }, + full = { hero = DBLSIZE, detail = STDSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -38,7 +51,13 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- 1/6: band + detail on one row, with the cheatsheet inline where the zone is +--- wide enough to carry all three. +--- A narrow zone stacks the cheatsheet under them instead, when the height and +--- the six columns both measure. It fits at all only on the container's padding: +--- at this tier PAD_SMALL top and bottom is the whole difference between two rows +--- and one. So the vertical padding gives and the left edge keeps it -- that edge +--- is what a widget in the zone above lines its own text up against. --- Fixed-width band column prevents layout jumping when values change. --- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) @@ -72,17 +91,72 @@ function WidgetUI.buildSixth(w, h, opa) for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end + WidgetLayout.row(w, h, opa, columns) + return end - WidgetLayout.row(w, h, opa, columns) + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet == nil then + WidgetLayout.row(w, h, opa, columns) + return + end + + -- Once the cheatsheet moves off the row there is width for the name, so the + -- widget gets to say what it is. Power and pit mode pay for it in their terse + -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the + -- reading come first and this row cannot hold all three in full. + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + }), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) end ---- 1/4: band + power, cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. +--- Power rides the headline rather than taking a row: two rows is all there is +--- here, and the cheatsheet has the other one. +--- The status line rides it too, which is the one place it does. This tier has no +--- spare row to hold it, so "VTX Admin Loading..." is the whole first line while +--- the VTX is quiet. function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.22) - local rows = { + local font = WidgetUI.fonts.quarter.status + local extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, { type = lvgl.LABEL, align = LEFT, @@ -91,33 +165,8 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - }, - }, } + local rows = { VTXDisplay.buildHeadline(w, font, extras) } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then rows[#rows + 1] = cheatsheet @@ -125,18 +174,54 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: title + band + power, cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/3: the headline, then the cheatsheet. +--- The name shares the headline with the reading rather than taking a line of +--- its own, as it does at 1/4: at one size, told apart by colour, they read as a +--- caption and its value. The line that buys puts the whole cheatsheet on one +--- row, which is what a full-width zone has the width for -- two rows of three +--- across the full 800 leave most of the widget empty. +--- A half-width zone keeps the two rows, the terse detail forms and the smaller +--- cheatsheet. function WidgetUI.buildThird(w, h, opa) - local c1w = math.floor(w * 0.22) + local wide = w >= WidgetUI.breakpoints.thirdWideW + local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third + local extras + if wide then + extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + visible = VTXDisplay.showChannel, + }, + } + else + extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + } + end local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, + VTXDisplay.buildHeadline(w, f.status, extras), + -- The status keeps a row of its own here, unlike at 1/4. This tier has the + -- height for it, and a hidden flex child costs nothing while the VTX is + -- tuned. { type = lvgl.LABEL, align = LEFT, @@ -145,37 +230,18 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.third.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.status, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - }, - }, } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 + if wide then + local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) + if cs then + rows[#rows + 1] = cs + end + else + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 + end end WidgetLayout.column(w, h, opa, rows) @@ -183,38 +249,9 @@ end --- 1/2: title + MIDSIZE band + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + local f = WidgetUI.fonts.half + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 @@ -225,38 +262,9 @@ end --- 1/1: title + DBLSIZE band + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + local f = WidgetUI.fonts.full + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 10fa307..4e50fdf 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -20,14 +20,19 @@ WidgetUI.breakpoints = { quarterH = 100, thirdH = 140, halfH = 210, + -- From here up a 1/3 zone spans the screen rather than half of it: the whole + -- cheatsheet goes on one row, and the band steps up to the size that width + -- affords. 320px is all there is, so the cheatsheet cannot follow it. + thirdWideW = 240, } WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, third = { status = BOLD, cheatsheet = STDSIZE }, + thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -169,74 +174,28 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: title + status + cheatsheet. ---- Fixed-width status column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/3: the headline, then the cheatsheet. +--- The name shares the headline with the reading rather than taking a line of +--- its own, as it does at 1/4: at one size, told apart by colour, they read as a +--- caption and its value. The line that buys puts the whole cheatsheet on one +--- row where the zone spans the screen; a half-width zone keeps the two rows. function WidgetUI.buildThird(w, h, opa) - local c1w = math.floor(w * 0.28) - local rows = {} - -- Title row - rows[#rows + 1] = { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - } - -- Loading state: full-width status label - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - -- Active state: fixed-width band column + detail - rows[#rows + 1] = { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.third.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = detailLine, - }, - }, - } - -- Cheatsheet rows - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) -end - ---- 1/2: title + band/channel + detail + cheatsheet. -function WidgetUI.buildHalf(w, h, opa) - local rows = { + local wide = w >= WidgetUI.breakpoints.thirdWideW + local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third + local extras = { { type = lvgl.LABEL, - font = BOLD, + align = LEFT, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", + -- Already the short form on this screen, so there is nothing terser to + -- fall back to on a narrow zone. + text = detailLine, + visible = VTXDisplay.showChannel, }, + } + local rows = { + VTXDisplay.buildHeadline(w, f.status, extras), { type = lvgl.LABEL, align = LEFT, @@ -245,23 +204,28 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = detailLong, - }, } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if wide then + local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) + if cs then + rows[#rows + 1] = cs + end + else + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band/channel + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local f = WidgetUI.fonts.half + local rows = VTXDisplay.buildHeroRows(f, detailLong) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 @@ -272,38 +236,9 @@ end --- 1/1: title + MIDSIZE band/channel + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + local f = WidgetUI.fonts.full + local rows = VTXDisplay.buildHeroRows(f, detailLong) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 2226fd0..c83834d 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -14,17 +14,29 @@ local WidgetUI = {} WidgetUI.breakpoints = { topBarW = 100, sixthH = 50, + -- The 1/6 tier stacks the cheatsheet on a second row from here up, + -- and only where the six columns have the width for it. + sixthStackH = 40, + sixthStackW = 180, quarterH = 70, thirdH = 100, halfH = 125, + -- From here up a 1/3 zone spans the screen rather than half of it: the whole + -- cheatsheet goes on one row, and the type steps up to the size that width + -- affords. + thirdWideW = 340, } WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, third = { status = BOLD, cheatsheet = SMLSIZE }, + -- A full-width 1/3 zone is 400px across and 80 tall for two lines. At the + -- half-width sizes those two lines fill its top third and leave the rest of + -- the panel empty, so both of them step up. + thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, half = { hero = BOLD, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -35,7 +47,14 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- 1/6: band + detail on one row, with the cheatsheet inline where the zone is +--- wide enough to carry all three. +--- A narrow zone stacks the cheatsheet under them instead, when the height and +--- the six columns both measure. It fits at all only on the container's +--- padding: at this tier PAD_SMALL top and bottom is the whole difference +--- between two rows and one. So the vertical padding gives and the left edge +--- keeps it -- that edge is what a widget in the zone above lines its own text +--- up against. --- Fixed-width band column prevents layout jumping when values change. --- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) @@ -69,51 +88,83 @@ function WidgetUI.buildSixth(w, h, opa) for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end + WidgetLayout.row(w, h, opa, columns) + return end - WidgetLayout.row(w, h, opa, columns) + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet == nil then + WidgetLayout.row(w, h, opa, columns) + return + end + + -- Once the cheatsheet moves off the row there is width for the name, so the + -- widget gets to say what it is. Power and pit mode pay for it in their terse + -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the + -- reading come first and this row cannot hold all three in full. + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + }), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) end ---- 1/4: two rows. Row 1: band + power. Row 2: cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. +--- Power rides the headline rather than taking a row: two rows is all there is +--- here, and the cheatsheet has the other one. +--- The status line rides it too, which is the one place it does. This tier has no +--- spare row to hold it, so "VTX Admin Loading..." is the whole first line while +--- the VTX is quiet -- and that is why the fit below is measured against the +--- status string as well as the band. function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.22) + local font = WidgetUI.fonts.quarter.status local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, + VTXDisplay.buildHeadline(w, font, { + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, }, - }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + }), } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then @@ -122,18 +173,54 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: title + band + detail, cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- 1/3: the headline, then the cheatsheet. +--- The name shares the headline with the reading rather than taking a line of +--- its own, as it does at 1/4: at one size, told apart by colour, they read as a +--- caption and its value. The line that buys puts the whole cheatsheet on one +--- row, which is what a full-width zone has the width for -- two rows of three +--- across 396px leave the right half of the widget empty. +--- A half-width zone has neither the width for six columns nor for the detail +--- line in full, so it keeps the two rows, the terse forms and the smaller type. function WidgetUI.buildThird(w, h, opa) - local c1w = math.floor(w * 0.22) + local wide = w >= WidgetUI.breakpoints.thirdWideW + local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third + local extras + if wide then + extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + visible = VTXDisplay.showChannel, + }, + } + else + extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + } + end local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, + VTXDisplay.buildHeadline(w, f.status, extras), + -- The status keeps a row of its own here, unlike at 1/4. This tier has the + -- height for it, and a hidden flex child costs nothing while the VTX is + -- tuned. { type = lvgl.LABEL, align = LEFT, @@ -142,37 +229,18 @@ function WidgetUI.buildThird(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.third.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - }, - }, - }, } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 + if wide then + local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) + if cs then + rows[#rows + 1] = cs + end + else + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 + end end WidgetLayout.column(w, h, opa, rows) @@ -180,38 +248,9 @@ end --- 1/2: title + band/status + detail + cheatsheet. function WidgetUI.buildHalf(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + local f = WidgetUI.fonts.half + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 @@ -222,38 +261,9 @@ end --- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + local f = WidgetUI.fonts.full + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 7e339b2..3c8f9e8 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -15,17 +15,28 @@ local WidgetUI = {} WidgetUI.breakpoints = { topBarW = 100, sixthH = 50, + -- The 1/6 tier stacks the cheatsheet on a second row from here up, + -- and only where the six columns have the width for it. + sixthStackH = 40, + sixthStackW = 180, quarterH = 62, thirdH = 118, halfH = 147, + -- From here up a 1/3 zone spans the screen rather than half of it: the whole + -- cheatsheet goes on one row, and the type steps up to the size that width + -- affords. + thirdWideW = 340, } WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, third = { status = BOLD, cheatsheet = SMLSIZE }, + -- Two lines at the half-width sizes fill the top third of a full-width zone + -- and leave the rest of the panel empty, so both of them step up. + thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, } -- ============================================================================ @@ -36,7 +47,13 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- 1/6: band + detail on one row, with the cheatsheet inline where the zone is +--- wide enough to carry all three. +--- A narrow zone stacks the cheatsheet under them instead, when the height and +--- the six columns both measure. It fits at all only on the container's padding: +--- at this tier PAD_SMALL top and bottom is the whole difference between two rows +--- and one. So the vertical padding gives and the left edge keeps it -- that edge +--- is what a widget in the zone above lines its own text up against. --- Fixed-width band column prevents layout jumping when values change. --- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) @@ -70,63 +87,94 @@ function WidgetUI.buildSixth(w, h, opa) for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end + WidgetLayout.row(w, h, opa, columns) + return end - WidgetLayout.row(w, h, opa, columns) + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet == nil then + WidgetLayout.row(w, h, opa, columns) + return + end + + -- Once the cheatsheet moves off the row there is width for the name, so the + -- widget gets to say what it is. Power and pit mode pay for it in their terse + -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the + -- reading come first and this row cannot hold all three in full. + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + }), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) end ---- 1/4: two rows. Row 1: band + power (+ pit mode when wide). Row 2: cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- 1/4: two rows. Row 1: the headline, power, and pit mode where it fits. +--- Row 2: cheatsheet. +--- Power rides the headline rather than taking a row: two rows is all there is +--- here, and the cheatsheet has the other one. +--- The status line rides it too, which is the one place it does. This tier has no +--- spare row to hold it, so "VTX Admin Loading..." is the whole first line while +--- the VTX is quiet. function WidgetUI.buildQuarter(w, h, opa) local wide = w > 200 - local c1w = math.floor(w * 0.22) - local row1 = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, + local font = WidgetUI.fonts.quarter.status + local extras = { { type = lvgl.LABEL, align = LEFT, - font = WidgetUI.fonts.quarter.status, + font = font, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, }, } if wide then - row1[#row1 + 1] = { + extras[#extras + 1] = { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = VTXDisplay.pitColor, text = VTXDisplay.pitText, + visible = VTXDisplay.showChannel, } end - local rows = { - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - visible = VTXDisplay.showChannel, - children = row1, - }, + extras[#extras + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, } + local rows = { VTXDisplay.buildHeadline(w, font, extras) } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then rows[#rows + 1] = cheatsheet @@ -134,72 +182,54 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: three rows — title, band + detail, cheatsheet. ---- 480x320 has enough room for a title row. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- 1/3: the headline, then the cheatsheet. +--- The name shares the headline with the reading rather than taking a line of +--- its own, as it does at 1/4: at one size, told apart by colour, they read as a +--- caption and its value. The line that buys puts the whole cheatsheet on one +--- row, which is what a full-width zone has the width for -- two rows of three +--- across 396px leave the right half of the widget empty. +--- A half-width zone has neither the width for six columns nor for the detail +--- line in full, so it keeps the two rows, the terse forms and the smaller type. function WidgetUI.buildThird(w, h, opa) - local c1w = math.floor(w * 0.22) - local rows = {} - -- Title row — 480x320 has more vertical room than 480x272 - rows[#rows + 1] = { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - rows[#rows + 1] = { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { + local wide = w >= WidgetUI.breakpoints.thirdWideW + local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third + local extras + if wide then + extras = { { type = lvgl.LABEL, - w = c1w, align = LEFT, - font = WidgetUI.fonts.third.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + visible = VTXDisplay.showChannel, }, + } + else + extras = { { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, }, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + } end - - WidgetLayout.column(w, h, opa, rows) -end - ---- 1/2: title + band/status + detail + cheatsheet. -function WidgetUI.buildHalf(w, h, opa) local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, + VTXDisplay.buildHeadline(w, f.status, extras), + -- The status keeps a row of its own here, unlike at 1/4. This tier has the + -- height for it, and a hidden flex child costs nothing while the VTX is + -- tuned. { type = lvgl.LABEL, align = LEFT, @@ -208,23 +238,28 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if wide then + local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) + if cs then + rows[#rows + 1] = cs + end + else + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band/status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local f = WidgetUI.fonts.half + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 @@ -235,38 +270,9 @@ end --- 1/1: title + band/status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + local f = WidgetUI.fonts.full + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index f366ec7..43364bd 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -18,12 +18,19 @@ WidgetUI.breakpoints = { quarterH = 54, thirdH = 76, halfH = 100, + -- From here up a 1/3 zone spans the screen rather than half of it: the whole + -- cheatsheet goes on one row, and the type steps up as far as 320x240 allows. + thirdWideW = 240, } WidgetUI.fonts = { sixth = { status = BOLD }, quarter = { status = BOLD }, third = { status = BOLD, cheatsheet = SMLSIZE }, + -- Only the cheatsheet steps up. The band stays BOLD because MIDSIZE is what + -- the 1/1 tier uses on this screen, and a 1/3 zone reading larger than the + -- whole-screen one is not a ladder. + thirdWide = { status = BOLD, cheatsheet = STDSIZE }, half = { hero = BOLD, detail = SMLSIZE, cheatsheet = SMLSIZE }, full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = SMLSIZE }, } @@ -80,12 +87,31 @@ function WidgetUI.buildSixth(w, h, opa) WidgetLayout.row(w, h, opa, columns) end ---- 1/4: two rows. Row 1: band + status + power + pit. Row 2: cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. +--- Power rides the headline rather than taking a row: two rows is all there is +--- here, and the cheatsheet has the other one. +--- The status line rides it too, which is the one place it does. This tier has no +--- spare row to hold it, so "VTX Admin Loading..." is the whole first line while +--- the VTX is quiet. function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.22) - local rows = { + local font = WidgetUI.fonts.quarter.status + local extras = { + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, { type = lvgl.LABEL, align = LEFT, @@ -94,40 +120,8 @@ function WidgetUI.buildQuarter(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = RED, - text = VTXDisplay.pitShort, - }, - }, - }, } + local rows = { VTXDisplay.buildHeadline(w, font, extras) } local cheatsheet = VTXDisplay.buildCheatsheet() if cheatsheet then rows[#rows + 1] = cheatsheet @@ -135,73 +129,51 @@ function WidgetUI.buildQuarter(w, h, opa) WidgetLayout.column(w, h, opa, rows) end ---- 1/3: band + status + power + pit mode, cheatsheet. No title on small screen. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/3: the headline, then the cheatsheet. +--- The name costs nothing here because it shares the headline with the reading: +--- at one size, told apart by colour, they read as a caption and its value. That +--- is what gets a title onto 320x240 at all, where a row of its own was always +--- too tight -- and the line it saves puts the whole cheatsheet on one row where +--- the zone is full width. +--- A half-width zone keeps the two rows, the terse detail forms and the smaller +--- cheatsheet. function WidgetUI.buildThird(w, h, opa) - local c1w = math.floor(w * 0.22) - local rows = {} - -- Loading state: full-width status label - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - -- Active state: band + power + pit mode row (no title — too tight on 320x240) - rows[#rows + 1] = { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { + local wide = w >= WidgetUI.breakpoints.thirdWideW + local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third + local extras + if wide then + extras = { { type = lvgl.LABEL, - w = c1w, align = LEFT, - font = WidgetUI.fonts.third.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + visible = VTXDisplay.showChannel, }, + } + else + extras = { { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, }, { type = lvgl.LABEL, align = LEFT, font = SMLSIZE, color = VTXDisplay.pitColor, - text = VTXDisplay.pitText, + text = VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, }, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 + } end - - WidgetLayout.column(w, h, opa, rows) -end - ---- 1/2: title + band + status + detail + cheatsheet. -function WidgetUI.buildHalf(w, h, opa) local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, + VTXDisplay.buildHeadline(w, f.status, extras), { type = lvgl.LABEL, align = LEFT, @@ -210,40 +182,28 @@ function WidgetUI.buildHalf(w, h, opa) text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.half.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitTextLong, - }, - }, - }, } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet) + if wide then + local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) + if cs then + rows[#rows + 1] = cs + end + else + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) + if cs1 then + rows[#rows + 1] = cs1 + rows[#rows + 1] = cs2 + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local f = WidgetUI.fonts.half + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 @@ -254,38 +214,9 @@ end --- 1/1: title + MIDSIZE band + status + detail + cheatsheet. function WidgetUI.buildFull(w, h, opa) - local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLong, - }, - } - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet) + local f = WidgetUI.fonts.full + local rows = VTXDisplay.buildHeroRows(f) + local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) if cs1 then rows[#rows + 1] = cs1 rows[#rows + 1] = cs2 From d59873865d9632e722ee62efe07c99566e172214 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 11:36:40 +0300 Subject: [PATCH 208/218] update simulator default vtx admin power level --- src/SCRIPTS/CRSFSimulator/csrfsimulator.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index 0a3b14a..88b9a4d 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -590,13 +590,18 @@ local txDevice = { units = "", }, { id = 12, parent = 10, type = CRSF.UINT8, name = "Channel", value = 1, min = 1, max = 8, units = "" }, + -- Power level 2, pit mode off: a VTX whose power ExpressLRS is managing. + -- At "-" the folder name drops the power and pit mode segments entirely and + -- the Pitmode field is hidden, so nothing downstream has a power level or a + -- pit state to render -- which makes it the wrong default for a mock whose + -- job is to exercise the display. { id = 13, parent = 10, type = CRSF.TEXT_SELECTION, name = "Pwr Lvl", options = "-;1;2;3;4;5;6;7;8", - value = 0, + value = 2, units = "", }, { From 9f601cb4f86f971b17f6d813f8a41aaff6851169 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 12:34:45 +0300 Subject: [PATCH 209/218] improve the layouts --- src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 30 +++++ src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 134 +++++++--------------- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 8 +- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 137 ++++++++--------------- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 134 +++++++--------------- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 43 +------ 6 files changed, 168 insertions(+), 318 deletions(-) diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index cf0b7eb..63d4145 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -285,6 +285,36 @@ function VTXDisplay.buildHeadline(w, font, extras) } end +--- The power and pit-mode labels that ride a headline row, in the form the width +--- affords: pit mode said in full where there is room for it, and terse -- "Pit" +--- rather than "Pit Mode Off" -- where the name and the reading come first and +--- the row cannot hold all three whole. +--- Two labels either way, because pit mode carries its own colour: a confirmed +--- pit mode is the one thing on this row worth going red, and a single label +--- holding both readings could only be one colour. +--- A caller with no spare row for the status appends its own status label after +--- these. +function VTXDisplay.buildDetailExtras(wide) + return { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = VTXDisplay.pitColor, + text = wide and VTXDisplay.pitText or VTXDisplay.pitShort, + visible = VTXDisplay.showChannel, + }, + } +end + --- The rows 1/2 and 1/1 share: the name, the band and channel as a hero on its --- own line, and the detail line. The caller appends its cheatsheet rows. --- detailText overrides the detail formatter, for a screen too narrow for the diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index e14f15b..9dc5b1d 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -24,10 +24,10 @@ WidgetUI.breakpoints = { quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) - -- From here up a 1/3 zone spans the screen rather than half of it: the whole - -- cheatsheet goes on one row, and the type steps up to the size that width - -- affords. - thirdWideW = 560, + -- From here up a zone spans the screen rather than half of it. What it buys + -- differs by tier: the whole cheatsheet on one row and a step up in type at + -- 1/3, pit mode said in full at 1/6. + wideW = 560, } WidgetUI.fonts = { @@ -51,17 +51,43 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: band + detail on one row, with the cheatsheet inline where the zone is ---- wide enough to carry all three. ---- A narrow zone stacks the cheatsheet under them instead, when the height and ---- the six columns both measure. It fits at all only on the container's padding: ---- at this tier PAD_SMALL top and bottom is the whole difference between two rows ---- and one. So the vertical padding gives and the left edge keeps it -- that edge ---- is what a widget in the zone above lines its own text up against. +--- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the whole cheatsheet on the row +--- below, the one above is left to the name, the reading and the detail, and none +--- of the three has to give. That is worth having at any width -- a full-width +--- zone can fit all four inline, but then the widget is a row of readings with +--- nothing saying whose they are, where every other tier says "VTX Admin". +--- It fits at all only on the container's padding: at this tier PAD_SMALL top and +--- bottom is the whole difference between two rows and one. So the vertical +--- padding gives and the left edge keeps it -- that edge is what a widget in the +--- zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, falls +--- back to a single inline row, which has no width to spare for the name. --- Fixed-width band column prevents layout jumping when values change. --- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) - local wide = w > 400 + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet then + local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) + extras[#extras + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + return + end + local c1w = math.floor(w * 0.22) local columns = { { @@ -86,58 +112,14 @@ function WidgetUI.buildSixth(w, h, opa) text = VTXDisplay.detailLine, }, } - if wide then + if w >= bp.wideW then local labels = VTXDisplay.build6posLabels() for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end - WidgetLayout.row(w, h, opa, columns) - return end - local bp = WidgetUI.breakpoints - local cheatsheet = nil - if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet == nil then - WidgetLayout.row(w, h, opa, columns) - return - end - - -- Once the cheatsheet moves off the row there is width for the name, so the - -- widget gets to say what it is. Power and pit mode pay for it in their terse - -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the - -- reading come first and this row cannot hold all three in full. - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - }), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. @@ -183,42 +165,10 @@ end --- A half-width zone keeps the two rows, the terse detail forms and the smaller --- cheatsheet. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.thirdWideW + local wide = w >= WidgetUI.breakpoints.wideW local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local extras - if wide then - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - visible = VTXDisplay.showChannel, - }, - } - else - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - } - end local rows = { - VTXDisplay.buildHeadline(w, f.status, extras), + VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), -- The status keeps a row of its own here, unlike at 1/4. This tier has the -- height for it, and a hidden flex child costs nothing while the VTX is -- tuned. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 4e50fdf..1c29400 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -20,10 +20,10 @@ WidgetUI.breakpoints = { quarterH = 100, thirdH = 140, halfH = 210, - -- From here up a 1/3 zone spans the screen rather than half of it: the whole - -- cheatsheet goes on one row, and the band steps up to the size that width + -- From here up a zone spans the screen rather than half of it: at 1/3 the whole + -- cheatsheet goes on one row and the band steps up to the size that width -- affords. 320px is all there is, so the cheatsheet cannot follow it. - thirdWideW = 240, + wideW = 240, } WidgetUI.fonts = { @@ -180,7 +180,7 @@ end --- caption and its value. The line that buys puts the whole cheatsheet on one --- row where the zone spans the screen; a half-width zone keeps the two rows. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.thirdWideW + local wide = w >= WidgetUI.breakpoints.wideW local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third local extras = { { diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index c83834d..4714d53 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -21,10 +21,12 @@ WidgetUI.breakpoints = { quarterH = 70, thirdH = 100, halfH = 125, - -- From here up a 1/3 zone spans the screen rather than half of it: the whole - -- cheatsheet goes on one row, and the type steps up to the size that width - -- affords. - thirdWideW = 340, + -- From here up a zone spans the screen rather than half of it. A half-screen + -- zone measures ~232 here and the full width of the placed layouts ~396, so + -- this separates the two with room either side. What it buys differs by tier: + -- the whole cheatsheet on one row and a step up in type at 1/3, pit mode said + -- in full at 1/6. + wideW = 340, } WidgetUI.fonts = { @@ -47,18 +49,43 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: band + detail on one row, with the cheatsheet inline where the zone is ---- wide enough to carry all three. ---- A narrow zone stacks the cheatsheet under them instead, when the height and ---- the six columns both measure. It fits at all only on the container's ---- padding: at this tier PAD_SMALL top and bottom is the whole difference ---- between two rows and one. So the vertical padding gives and the left edge ---- keeps it -- that edge is what a widget in the zone above lines its own text ---- up against. +--- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the whole cheatsheet on the row +--- below, the one above is left to the name, the reading and the detail, and none +--- of the three has to give. That is worth having at any width -- a full-width +--- zone can fit all four inline, but then the widget is a row of readings with +--- nothing saying whose they are, where every other tier says "VTX Admin". +--- It fits at all only on the container's padding: at this tier PAD_SMALL top and +--- bottom is the whole difference between two rows and one. So the vertical +--- padding gives and the left edge keeps it -- that edge is what a widget in the +--- zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, falls +--- back to a single inline row, which has no width to spare for the name. --- Fixed-width band column prevents layout jumping when values change. --- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) - local wide = w > 200 + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet then + local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) + extras[#extras + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + return + end + local c1w = math.floor(w * 0.22) local columns = { { @@ -83,58 +110,14 @@ function WidgetUI.buildSixth(w, h, opa) text = VTXDisplay.detailLine, }, } - if wide then + if w >= bp.wideW then local labels = VTXDisplay.build6posLabels() for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end - WidgetLayout.row(w, h, opa, columns) - return end - local bp = WidgetUI.breakpoints - local cheatsheet = nil - if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet == nil then - WidgetLayout.row(w, h, opa, columns) - return - end - - -- Once the cheatsheet moves off the row there is width for the name, so the - -- widget gets to say what it is. Power and pit mode pay for it in their terse - -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the - -- reading come first and this row cannot hold all three in full. - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - }), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. @@ -182,42 +165,10 @@ end --- A half-width zone has neither the width for six columns nor for the detail --- line in full, so it keeps the two rows, the terse forms and the smaller type. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.thirdWideW + local wide = w >= WidgetUI.breakpoints.wideW local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local extras - if wide then - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - visible = VTXDisplay.showChannel, - }, - } - else - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - } - end local rows = { - VTXDisplay.buildHeadline(w, f.status, extras), + VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), -- The status keeps a row of its own here, unlike at 1/4. This tier has the -- height for it, and a hidden flex child costs nothing while the VTX is -- tuned. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 3c8f9e8..f3d7b70 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -22,10 +22,10 @@ WidgetUI.breakpoints = { quarterH = 62, thirdH = 118, halfH = 147, - -- From here up a 1/3 zone spans the screen rather than half of it: the whole - -- cheatsheet goes on one row, and the type steps up to the size that width - -- affords. - thirdWideW = 340, + -- From here up a zone spans the screen rather than half of it. What it buys + -- differs by tier: the whole cheatsheet on one row and a step up in type at + -- 1/3, pit mode said in full at 1/6. + wideW = 340, } WidgetUI.fonts = { @@ -47,17 +47,43 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: band + detail on one row, with the cheatsheet inline where the zone is ---- wide enough to carry all three. ---- A narrow zone stacks the cheatsheet under them instead, when the height and ---- the six columns both measure. It fits at all only on the container's padding: ---- at this tier PAD_SMALL top and bottom is the whole difference between two rows ---- and one. So the vertical padding gives and the left edge keeps it -- that edge ---- is what a widget in the zone above lines its own text up against. +--- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the whole cheatsheet on the row +--- below, the one above is left to the name, the reading and the detail, and none +--- of the three has to give. That is worth having at any width -- a full-width +--- zone can fit all four inline, but then the widget is a row of readings with +--- nothing saying whose they are, where every other tier says "VTX Admin". +--- It fits at all only on the container's padding: at this tier PAD_SMALL top and +--- bottom is the whole difference between two rows and one. So the vertical +--- padding gives and the left edge keeps it -- that edge is what a widget in the +--- zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, falls +--- back to a single inline row, which has no width to spare for the name. --- Fixed-width band column prevents layout jumping when values change. --- Status text (loading/error/off) uses unconstrained label for narrow columns. function WidgetUI.buildSixth(w, h, opa) - local wide = w > 200 + local bp = WidgetUI.breakpoints + local cheatsheet = nil + if h >= bp.sixthStackH and w >= bp.sixthStackW then + cheatsheet = VTXDisplay.buildCheatsheet() + end + if cheatsheet then + local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) + extras[#extras + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), + cheatsheet, + }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + return + end + local c1w = math.floor(w * 0.22) local columns = { { @@ -82,58 +108,14 @@ function WidgetUI.buildSixth(w, h, opa) text = VTXDisplay.detailLine, }, } - if wide then + if w >= bp.wideW then local labels = VTXDisplay.build6posLabels() for _, lbl in ipairs(labels) do columns[#columns + 1] = lbl end - WidgetLayout.row(w, h, opa, columns) - return - end - - local bp = WidgetUI.breakpoints - local cheatsheet = nil - if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet == nil then - WidgetLayout.row(w, h, opa, columns) - return end - -- Once the cheatsheet moves off the row there is width for the name, so the - -- widget gets to say what it is. Power and pit mode pay for it in their terse - -- forms -- "P2 Pit" rather than "P2 Pit Mode Off" -- because the name and the - -- reading come first and this row cannot hold all three in full. - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - }), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, power, and pit mode where it fits. @@ -191,42 +173,10 @@ end --- A half-width zone has neither the width for six columns nor for the detail --- line in full, so it keeps the two rows, the terse forms and the smaller type. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.thirdWideW + local wide = w >= WidgetUI.breakpoints.wideW local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local extras - if wide then - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - visible = VTXDisplay.showChannel, - }, - } - else - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - } - end local rows = { - VTXDisplay.buildHeadline(w, f.status, extras), + VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), -- The status keeps a row of its own here, unlike at 1/4. This tier has the -- height for it, and a hidden flex child costs nothing while the VTX is -- tuned. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 43364bd..e131e02 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -18,9 +18,10 @@ WidgetUI.breakpoints = { quarterH = 54, thirdH = 76, halfH = 100, - -- From here up a 1/3 zone spans the screen rather than half of it: the whole - -- cheatsheet goes on one row, and the type steps up as far as 320x240 allows. - thirdWideW = 240, + -- From here up a zone spans the screen rather than half of it: at 1/3 the whole + -- cheatsheet goes on one row and the type steps up as far as 320x240 allows, + -- and pit mode is said in full. + wideW = 240, } WidgetUI.fonts = { @@ -138,42 +139,10 @@ end --- A half-width zone keeps the two rows, the terse detail forms and the smaller --- cheatsheet. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.thirdWideW + local wide = w >= WidgetUI.breakpoints.wideW local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local extras - if wide then - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - visible = VTXDisplay.showChannel, - }, - } - else - extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - } - end local rows = { - VTXDisplay.buildHeadline(w, f.status, extras), + VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), { type = lvgl.LABEL, align = LEFT, From a1a49279bbf43d0b4109c1868df8b4442f793475 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 13:49:46 +0300 Subject: [PATCH 210/218] add name to telemetry widget --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 193 +++++++++++++++++--- 1 file changed, 166 insertions(+), 27 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index cb0b846..0052909 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -31,6 +31,19 @@ local Components = {} -- extent, not so much that the empty half draws the eye before the fill. local TRACK_OPACITY = 90 +-- The widget's own name, and the short form for a zone too narrow for it. +-- Written the way ExpressLRS writes it rather than uppercased like the inline +-- headers further down: this is a name, not a caption, and the same reason +-- keeps the mixed case in TQly and TRSS. +local BRAND = "ExpressLRS" +local BRAND_SHORT = "ELRS" + +--- Side of one antenna cell. Half the row, so the pair sits at the LED's +--- weight rather than the text's. +local function antCell(m) + return math.floor(m.sml / 2) + 1 +end + -- ============================================================================ -- Metrics -- ============================================================================ @@ -79,6 +92,27 @@ function Components.frame(w, h, m) } end +--- The widget's name in the longest form that fits availW, or nil when even +--- the short one does not. +--- A ladder rather than a per-radio breakpoint, for the same reason the group +--- row has one: what fits is a measured width in this screen's font. +function Components.brandText(availW) + if availW >= Components.textWidth(BRAND, SMLSIZE) then + return BRAND + end + if availW >= Components.textWidth(BRAND_SHORT, SMLSIZE) then + return BRAND_SHORT + end + return nil +end + +--- The name in the form the panel's top row can carry beside the LQ reading. +--- Measured against the widest reading rather than the current one, so the +--- mark does not move when the number gains a digit. +function Components.panelBrand(w, m, lqFont) + return Components.brandText(w - Components.textWidth("LQ 100%", lqFont) - m.pad * 3) +end + -- ============================================================================ -- Elements -- ============================================================================ @@ -218,6 +252,9 @@ end --- COLOR_THEME_SECONDARY2 hairline disappears into COLOR_THEME_PRIMARY2 on --- the light themes, which took the meter's scale with it -- and a meter --- whose empty half cannot be seen is just a short bar. +--- spec.visible hides the whole meter, cells and separators included. Every +--- piece needs it: a track left behind while its header and reading hide is +--- what a banner drawn over the strip runs into. function Components.segments(dst, rect, y, spec) local n = spec.count local cw = math.floor(rect.w / n) @@ -232,6 +269,7 @@ function Components.segments(dst, rect, y, spec) color = COLOR_THEME_DISABLED, opacity = TRACK_OPACITY, filled = true, + visible = spec.visible, } dst[#dst + 1] = { type = lvgl.RECTANGLE, @@ -251,6 +289,9 @@ function Components.segments(dst, rect, y, spec) -- At zero steps the size closure would still have to return 1px, so the -- meter empties by hiding the fill rather than by shrinking it. visible = function() + if spec.visible and not spec.visible() then + return false + end return spec.steps() > 0 end, } @@ -263,6 +304,7 @@ function Components.segments(dst, rect, y, spec) h = h, color = COLOR_THEME_PRIMARY2, filled = true, + visible = spec.visible, } end return y + h @@ -273,27 +315,42 @@ end --- RF path is carrying the link in 20px, where a text row costs a whole line. --- The second cell hides on a single-path receiver rather than sitting dark, --- which would imply an antenna that is not there. -function Components.antenna(dst, rect, y, m) - local cell = m.sml - 4 +--- The whole group hides on a mismatch, along with everything else the banner +--- displaces: the strip is not wide enough to hold both, and cells left behind +--- are what "MODEL MISMATCH!" ends up printed across. +--- Sized off the LED beside them rather than off the row: at row height the two +--- cells are solid blocks that take the eye before any reading on the strip +--- does, which is backwards for a qualifier. As a pair of small marks they read +--- as the same class of thing the LED is, and they cost the strip less width. +function Components.antenna(dst, rect, y, m, labelled) + local cell = antCell(m) local gap = m.gap - Components.label(dst, { - x = rect.x, - y = y, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = "ANT", - }) - local cx = rect.x + Components.textWidth("ANT ", SMLSIZE) + local cx = rect.x + if labelled then + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "ANT", + visible = Display.isNotMismatch, + }) + cx = rect.x + Components.textWidth("ANT ", SMLSIZE) + end for i = 1, 2 do dst[#dst + 1] = { type = lvgl.RECTANGLE, x = cx + (i - 1) * (cell + gap), - y = y + 2, + -- Centred on the text line rather than pinned near its top, which is + -- where a cell shorter than the row would otherwise sit. + y = y + math.floor((m.sml - cell) / 2), w = cell, h = cell, color = Display.antColor(i), filled = true, - visible = (i == 2) and Display.hasDiversity or nil, + visible = (i == 2) and function() + return Display.isNotMismatch() and Display.hasDiversity() + end or Display.isNotMismatch, } end return cx + 2 * cell + gap @@ -303,9 +360,16 @@ end -- Blocks -- ============================================================================ ---- The status strip: LED, RF mode, optionally the power meter, antenna cells. ---- A model mismatch takes the whole strip. The binding is wrong, which is ---- worth the row -- and the elements it displaces take the inverse visible +--- The status strip: LED, the name, RF mode, optionally the power meter, +--- antenna cells. Returns the next free y and whether it took the name, so a +--- tier with no name on its strip can put one on the panel instead. +--- The name belongs here because the strip is the module row -- what it is +--- transmitting at, on which antenna -- so "ExpressLRS 150Hz" reads as a +--- header and its value, which is the grammar the captioned groups already +--- use. It stays through a mismatch: knowing which widget is shouting is +--- worth more then than at any other time. +--- A model mismatch takes the rest of the strip. The binding is wrong, which +--- is worth the row -- and the elements it displaces take the inverse visible --- rather than being left out, so the banner lands in the same rect and --- nothing below it moves. --- Only a mismatch gets a banner. "No telemetry" is what the widget shows on @@ -332,6 +396,64 @@ function Components.statusStrip(dst, rect, y, m, spec) }) local textX = rect.x + lane + m.pad + -- Right-hand end, laid out from the right edge inward. Measured before the + -- name because it is what the name has to fit inside of. + local antW = Components.textWidth("ANT ", SMLSIZE) + 2 * antCell(m) + m.gap + local antX = rect.x + rect.w - antW + -- The mode's x is reserved against the widest rate name the tables carry, so + -- switching from 25Hz to K1000Full never pushes what follows sideways. + local modeW = Components.textWidth("K1000Full", SMLSIZE) + -- Measured against the power reading rather than the whole meter, so the + -- name outranks the meter's cells but never its number. The cells only + -- illustrate a figure that stays on the strip either way, and where they go + -- the existing fallback below puts "50 mW" in the gap instead -- so the strip + -- keeps everything it was saying and gains the name. A widget that says whose + -- readings these are is worth more than a lit-cell count of one of them. + local powerW = 0 + if spec.power or spec.powerText then + powerW = Components.textWidth("2000 mW", SMLSIZE) + m.pad * 2 + end + -- The mode-to-power gap is only reserved when there is a power element to + -- separate the mode from. On a tier that gives TX power its own row this was + -- holding 12px open for nothing, and the name is what it held them from. + local gapToPower = powerW > 0 and m.pad * 3 or m.pad + -- Wider than the strip's other gaps. At one size, with only colour telling + -- the name from the reading, a tight gap leaves "ExpressLRS 150Hz" reading + -- as one run-together word. + local nameGap = m.pad * 2 + local slack = antX - (textX + modeW + gapToPower + powerW) + local antLabelled = true + -- Only the full name goes on the strip. Here it is a header to the value + -- beside it, and an abbreviated header reads as a strip that ran out of + -- room; the same abbreviation on the panel row still reads as a mark. So a + -- strip with space for the short form only passes the name down instead. + local named = Components.brandText(slack - nameGap) == BRAND + if not named then + -- Short by less than the antenna caption is wide, so the caption is what + -- the name is standing behind. The cells are the reading -- which path is + -- carrying the link -- and "ANT" only says what they are, so on a strip + -- that has to choose, the word goes and the widget gets its name. A + -- half-width 480x272 zone misses the full name by a single pixel with the + -- word in place, which is not a margin worth an anonymous widget. + local bareW = 2 * antCell(m) + m.gap + local bareSlack = slack + (antW - bareW) + if Components.brandText(bareSlack - nameGap) == BRAND then + named, antLabelled, antW, slack = true, false, bareW, bareSlack + antX = rect.x + rect.w - antW + end + end + if named then + Components.label(dst, { + x = textX, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + -- A constant, so it costs no closure and no per-frame string hash. + text = BRAND, + }) + textX = textX + Components.textWidth(BRAND, SMLSIZE) + nameGap + end + Components.label(dst, { x = textX, y = y, @@ -352,17 +474,11 @@ function Components.statusStrip(dst, rect, y, m, spec) visible = Display.isMismatch, }) - -- Right-hand end, laid out from the right edge inward. - local antW = Components.textWidth("ANT ", SMLSIZE) + 2 * (m.sml - 4) + m.gap - local antX = rect.x + rect.w - antW - Components.antenna(dst, { x = antX }, y, m) + Components.antenna(dst, { x = antX }, y, m, antLabelled) -- The meter goes in the gap between the RF mode and the antenna cells, at - -- its natural width and only when it genuinely fits there. Its x is measured - -- against the widest rate name the tables carry, so switching from 25Hz to - -- K1000Full never pushes it sideways. - local modeW = Components.textWidth("K1000Full", SMLSIZE) - local powerX = textX + modeW + m.pad * 3 + -- its natural width and only when it genuinely fits there. + local powerX = textX + modeW + gapToPower local meterFits = powerX + Components.powerGroupWidth(m) <= antX if spec.power and meterFits then Components.powerGroup(dst, { x = powerX }, y, m) @@ -388,7 +504,7 @@ function Components.statusStrip(dst, rect, y, m, spec) visible = Display.isNotMismatch, }) end - return y + h + return y + h, named end --- Width of the TX POWER group, so a caller can place it without stretching @@ -421,6 +537,7 @@ function Components.powerGroup(dst, rect, y, m) Components.segments(dst, { x = rect.x + headW, w = meterW }, y + 2, { count = Display.powerStepCount(), h = m.sml - 4, + visible = Display.isNotMismatch, -- Accent, not COLOR_THEME_PRIMARY1. The lit cells are a filled area, and -- the text colour used as a fill is a black bar sitting on a blue panel. -- Power is also not a health reading -- 500 mW is not worse than 50 -- so @@ -456,6 +573,10 @@ end --- margin than there could be", which is exactly what is worth seeing early. --- spec.lqBar and spec.headroomBar drop either bar for the smaller tiers; --- spec.endpoints adds the headroom scale's numbers where width allows. +--- spec.brand puts the widget's name at the right end of the LQ row, for a +--- tier whose strip had no room for it. That is a mark, not a header: it names +--- the widget, in the one place on the panel nothing else uses, and it does not +--- claim the block below it is "the uplink" as a caption there would. function Components.uplinkPanel(dst, rect, y, m, spec) Components.label(dst, { x = rect.x, @@ -464,6 +585,20 @@ function Components.uplinkPanel(dst, rect, y, m, spec) color = Display.lqTextColor, text = Display.lqText, }) + if spec.brand then + Components.label(dst, { + x = rect.x, + -- Sat on the reading's baseline rather than its top. Labels position from + -- the top, so small type beside a large number has to be dropped by the + -- difference or it floats above the digits it sits beside. + y = y + math.max(0, spec.lqH - m.sml), + w = rect.w, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = spec.brand, + }) + end y = y + spec.lqH if spec.lqBar then y = Components.bar(dst, rect, y, { @@ -703,7 +838,7 @@ function Components.fullTier(w, h, opa, m, spec) local root = {} local panel = Components.panel(root, f.panel, { opacity = opa }) - local y = Components.statusStrip(panel, inner, pad, m, { power = wide }) + local y, named = Components.statusStrip(panel, inner, pad, m, { power = wide }) y = Components.rule(panel, inner, y + air) y = Components.uplinkPanel(panel, inner, y + air, m, { lqFont = spec.lqFont, @@ -712,6 +847,9 @@ function Components.fullTier(w, h, opa, m, spec) lqBar = true, headroomBar = true, endpoints = wide, + -- A narrow strip has nothing to spare between the RF mode and the antenna + -- cells, and the LQ row has a whole empty right half. + brand = not named and Components.panelBrand(inner.w, m, spec.lqFont) or nil, }) y = Components.rule(panel, inner, y + air) + air if not wide then @@ -768,13 +906,14 @@ function Components.halfTier(w, h, opa, m, spec) local root = {} local panel = Components.panel(root, f.panel, { opacity = opa }) - local y = Components.statusStrip(panel, inner, pad, m, { powerText = true }) + local y, named = Components.statusStrip(panel, inner, pad, m, { powerText = true }) y = Components.uplinkPanel(panel, inner, y + air, m, { lqFont = spec.lqFont, lqH = spec.lqH, barH = barH, lqBar = true, headroomBar = true, + brand = not named and Components.panelBrand(inner.w, m, spec.lqFont) or nil, }) if showBattery then Components.groupRow(panel, inner, y + air, m, { battery }) From ca943f4a1203a781677f6b9247bb1183fb4b9da0 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 13:58:21 +0300 Subject: [PATCH 211/218] fix mismatch --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 38 ++++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index 0052909..351d9b9 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -38,6 +38,11 @@ local TRACK_OPACITY = 90 local BRAND = "ExpressLRS" local BRAND_SHORT = "ELRS" +-- The mismatch banner, and the form for a strip that carries the name as well. +-- Caps either way: this is the one thing on the strip worth interrupting for. +local MISMATCH = "MODEL MISMATCH!" +local MISMATCH_SHORT = "MISMATCH!" + --- Side of one antenna cell. Half the row, so the pair sits at the LED's --- weight rather than the text's. local function antCell(m) @@ -442,16 +447,39 @@ function Components.statusStrip(dst, rect, y, m, spec) antX = rect.x + rect.w - antW end end + local nameX = textX + if named then + textX = textX + Components.textWidth(BRAND, SMLSIZE) + nameGap + end + + -- The banner runs from wherever the readings start to the end of the strip, + -- so putting the name first takes width off it. Shortened rather than + -- squeezed: a banner is no use clipped, and "MISMATCH!" is unambiguous under + -- a name that has just said which link it is about. + local banner = MISMATCH + local bannerX = textX + if Components.textWidth(banner, BOLD) > rect.x + rect.w - bannerX then + banner = MISMATCH_SHORT + end + -- Nothing left that fits, so the name yields the strip for as long as the + -- mismatch stands. Last resort, and in the other direction from everywhere + -- else here: the binding being wrong outranks even saying whose binding. + local nameHides = Components.textWidth(banner, BOLD) > rect.x + rect.w - bannerX + if nameHides then + bannerX = nameX + end + if named then Components.label(dst, { - x = textX, + x = nameX, y = y, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - -- A constant, so it costs no closure and no per-frame string hash. + -- A constant, so it costs no closure and no per-frame string hash -- + -- except in the one case where the banner needs its width. text = BRAND, + visible = nameHides and Display.isNotMismatch or nil, }) - textX = textX + Components.textWidth(BRAND, SMLSIZE) + nameGap end Components.label(dst, { @@ -463,14 +491,14 @@ function Components.statusStrip(dst, rect, y, m, spec) visible = Display.isNotMismatch, }) Components.label(dst, { - x = textX, + x = bannerX, y = y, font = BOLD, color = COLOR_THEME_WARNING, -- A constant label plus a bool closure, not a formatter: statusText() -- keeps title case for the full-screen subtitle it also feeds, and a -- string closure would be hashed every frame to say the same thing. - text = "MODEL MISMATCH!", + text = banner, visible = Display.isMismatch, }) From cc449888b26f5d9e625aaca0024910d1e813cd96 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 14:39:38 +0300 Subject: [PATCH 212/218] update layouts --- src/WIDGETS/ELRSTelemetry/loadable.lua | 3 +- src/WIDGETS/ELRSTelemetry/ui/components.lua | 227 ++++++++++++++++++-- src/WIDGETS/ELRSTelemetry/ui/display.lua | 64 +----- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 186 +--------------- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 161 +------------- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 178 +-------------- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 186 +--------------- src/WIDGETS/ELRSTelemetry/ui/small.lua | 178 +-------------- 8 files changed, 262 insertions(+), 921 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua index bae8740..d00fa9e 100644 --- a/src/WIDGETS/ELRSTelemetry/loadable.lua +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -18,7 +18,7 @@ local zone, options, Telemetry = ... -- Display components -- ============================================================================ -local Display, WidgetLayout = loadScript("/WIDGETS/ELRSTelemetry/ui/display.lua")(Telemetry) +local Display = loadScript("/WIDGETS/ELRSTelemetry/ui/display.lua")(Telemetry) local Components = loadScript("/WIDGETS/ELRSTelemetry/ui/components.lua")(Display) -- ============================================================================ @@ -52,7 +52,6 @@ local uiPath = table.concat({ "/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua" }) local WidgetUI = loadScript(uiPath)({ Display = Display, bgOpacity = bgOpacity, - WidgetLayout = WidgetLayout, Components = Components, }) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index 351d9b9..c4e5c4b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -236,6 +236,29 @@ function Components.bar(dst, rect, y, spec) return y + h end +--- The LED in a fixed lane at the left of a row, returning the x where text +--- starts. The lane is what everything after it measures from, so the dot's +--- radius can be tuned without the readings shifting sideways. +--- Shared with the tiers that have no status strip, so the LED sits at one +--- x on every tier and a widget resized between them does not appear to move +--- its indicator. +function Components.ledLane(dst, rect, y, m) + local lane = math.floor(m.sml / 2) + 2 + Components.led(dst, { + x = rect.x + math.floor(lane / 2), + -- Centred on the text's line box, not on the row. The row is the line box + -- plus padding, so centring in it drops the dot below the text it sits + -- beside -- close enough to look like a mistake rather than a choice. + y = y + math.floor(m.sml / 2), + -- Deliberately smaller than the lane. An indicator only has to be seen, and + -- at half the row height it stops reading as a dot and starts competing + -- with the text beside it. + radius = math.floor(m.sml / 4) + 1, + color = Display.ledColor, + }) + return rect.x + lane + m.pad +end + --- The status LED. x and y are the centre, not a corner. function Components.led(dst, spec) dst[#dst + 1] = { @@ -382,24 +405,7 @@ end --- teaches the eye to ignore the banner that matters. function Components.statusStrip(dst, rect, y, m, spec) local h = m.sml + 4 - -- The LED gets a fixed lane sized off the row, and sits centred in it. The - -- lane is what the text after it measures from, so the dot's radius can be - -- tuned without the RF mode shifting sideways. - local lane = math.floor(m.sml / 2) + 2 - -- Deliberately smaller than the lane. An indicator only has to be seen, and - -- at half the row height it stops reading as a dot and starts competing with - -- the text it sits beside. - local r = math.floor(m.sml / 4) + 1 - Components.led(dst, { - x = rect.x + math.floor(lane / 2), - -- Centred on the text's line box, not on the strip. The strip is the line - -- box plus padding, so centring in it drops the dot below the RF mode it - -- sits beside -- close enough to look like a mistake rather than a choice. - y = y + math.floor(m.sml / 2), - radius = r, - color = Display.ledColor, - }) - local textX = rect.x + lane + m.pad + local textX = Components.ledLane(dst, rect, y, m) -- Right-hand end, laid out from the right edge inward. Measured before the -- name because it is what the name has to fit inside of. @@ -950,6 +956,191 @@ function Components.halfTier(w, h, opa, m, spec) lvgl.build(root) end +--- The 1/3 tier: the strip, then LQ and the RSSI pair sharing a row, then the +--- headroom bar. +--- Three rows is all there is here, and the strip keeps one of them: it is what +--- carries the name, the packet rate, the power reading, the antenna and the +--- mismatch banner, all of which would otherwise have to be given up one at a +--- time. That is what pays for putting LQ and the RSSI pair on one row -- the +--- taller tiers give RSSI a caption row of its own, and here that row is the +--- bar's height instead. +--- Losing the caption costs nothing: "-85 / -108 dBm" carries its own unit, and +--- with one bar left there is no second reading it could be mistaken for. +--- The LQ bar is what goes. Between the two, the headroom bar is the one that +--- says something the number beside it does not -- it recalibrates with the +--- packet rate -- where an LQ bar plots a percentage that is already legible as +--- a percentage. +function Components.thirdTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + local AIR_GAPS = 2 + -- Thinner than this and a bar is a line, not a meter. + local MIN_BAR = 3 + local fixed = pad * 2 + + (m.sml + 4) -- status strip + + spec.lqH + local slack = f.h - fixed + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.5), 14)) + local air = math.max(0, math.floor((slack - barH) / AIR_GAPS)) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + + local y = Components.statusStrip(panel, inner, pad, m, { powerText = true }) + air + Components.label(panel, { + x = inner.x, + y = y, + font = spec.lqFont, + color = Display.lqTextColor, + text = Display.lqText, + }) + Components.label(panel, { + x = inner.x, + -- On the LQ reading's baseline, like the panel's wordmark: labels position + -- from the top, so small type beside a large number has to be dropped by + -- the difference between them. + y = y + math.max(0, spec.lqH - m.sml), + w = inner.w, + align = RIGHT, + font = SMLSIZE, + color = Display.detailColor, + text = Display.signalText, + }) + y = y + spec.lqH + air + Components.bar(panel, inner, y, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, + }) + + lvgl.build(root) +end + +--- The short tiers -- 1/4 and 1/6 -- as one composition: a row of readings over +--- the headroom bar, with the bar thinning and the row shedding cells as the +--- zone shrinks. +--- One function for both because the difference between them turned out to be +--- a bar height and a cell count, both of which are measured here anyway. Two +--- builders differing only in that were two places to fix the same bug. +--- The strip is what goes at this height, and the mode, power and antenna go +--- with it. Two rows cannot hold a strip and a bar, and the bar is what this +--- widget is: below the strip the choice is between a row of numbers any +--- telemetry screen could show and the one instrument only this widget has. +--- The RSSI pair keeps the left edge because the bar grows from there and plots +--- exactly that reading. LQ goes to the right, so it reads as the number it is +--- rather than as the bar's label. +--- Then the packet rate and power come back as one cell where the width allows, +--- and the name after them -- readings before identity, since the mode and the +--- power are things only this row can tell you at this size. +--- The LED keeps its lane, so a link that is down still says so on the tier +--- with no room to say it in words, and the hero label falls back to the status +--- text: there is no strip here to carry a banner. +function Components.compactTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + -- Thinner than this and a bar is a line, not a meter. Below it the row takes + -- the whole panel and centres in it, rather than sitting above a smear. + local MIN_BAR = 3 + local vpad = pad + local slack = f.h - (vpad * 2 + spec.lqH) + if slack < MIN_BAR then + -- A 1/6 zone is 28px on 480x272, which is the row and its padding and + -- nothing else. So the padding gives: the left and right edges keep theirs, + -- because that is what a widget in the zone above lines its text up + -- against, and the top and bottom drop to the smallest gap the theme has. + -- Worth it for 3px of bar, because the bar is the reading that no other + -- widget on the screen offers -- and losing it here is losing it at the + -- size the widget is most often squeezed into. + vpad = m.gap + slack = f.h - (vpad * 2 + spec.lqH) + end + local barH = slack >= MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.6), 12)) or 0 + local air = math.max(0, slack - barH) + -- Small type beside a large number sits on its baseline, not its top. + local drop = math.max(0, spec.lqH - m.sml) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + -- With no bar the row has the panel to itself, so it centres; with one it + -- keeps the top and the bar takes the slack. + local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) + local textX = Components.ledLane(panel, inner, y, m) + local right = inner.x + inner.w + + local detailW = Components.textWidth("K1000Full 2000mW", SMLSIZE) + local avail = right + - textX + - Components.textWidth("-105 / -105 dBm", SMLSIZE) + - Components.textWidth("LQ 100%", spec.lqFont) + - m.pad * 3 + local showDetail = avail >= detailW + m.pad + if showDetail then + avail = avail - detailW - m.pad + end + local brand = Components.brandText(avail) + + if brand then + local brandW = Components.textWidth(brand, SMLSIZE) + Components.label(panel, { + x = right - brandW, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = brand, + }) + right = right - brandW - m.pad * 2 + end + if showDetail then + Components.label(panel, { + x = right - detailW, + y = y + drop, + w = detailW, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.rfDetailText, + -- Hidden with the other readings while the hero carries a status, which + -- is the one case a long hero string reaches this far across the row. + visible = Display.isNotMismatch, + }) + right = right - detailW - m.pad + end + + Components.label(panel, { + x = textX, + y = y, + w = right - textX, + align = RIGHT, + font = spec.lqFont, + color = Display.heroColor, + -- The status when there is one: no strip means no banner, so this label is + -- the only thing that can say the link is down or the model is wrong. It is + -- right-aligned, so a long status grows leftward across the row it has just + -- emptied rather than into the mark at its end. + text = Display.heroText, + }) + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = Display.detailColor, + text = Display.signalText, + visible = Display.isNotMismatch, + }) + + if barH > 0 then + Components.bar(panel, inner, vpad + spec.lqH + air, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, + }) + end + + lvgl.build(root) +end + -- ============================================================================ -- Return components -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index 611c3fe..b45a728 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -1,7 +1,7 @@ --------------------------------------------------------------------------- -- ELRS Telemetry Widget - Display Components -- -- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- --- (Telemetry); returns (Display, WidgetLayout). -- +-- (Telemetry); returns the Display table. -- -- -- -- Display is the read model the ui/ files consume: zero-argument -- -- formatters passed by reference as LVGL text/color callbacks, so they -- @@ -17,66 +17,6 @@ local Telemetry = ... local Display = {} --- ============================================================================ --- WidgetLayout: minimized zone container builders --- ============================================================================ - -local WidgetLayout = {} - -function WidgetLayout.column(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT, - flexFlow = lvgl.FLOW_COLUMN, - flexPad = 0, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end - -function WidgetLayout.row(w, h, opa, children) - lvgl.build({ - { - type = lvgl.RECTANGLE, - x = 0, - y = 0, - w = w, - h = h, - color = COLOR_THEME_PRIMARY2, - opacity = opa, - filled = true, - }, - { - type = lvgl.BOX, - x = 0, - y = 0, - w = w, - h = h, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = lvgl.PAD_SMALL, - children = children, - }, - }) -end - -- ============================================================================ -- Link state, as the view asks about it -- ============================================================================ @@ -523,4 +463,4 @@ end -- Return components -- ============================================================================ -return Display, WidgetLayout +return Display diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 9da112b..aea08fd 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -6,7 +6,6 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity -local WidgetLayout = ctx.WidgetLayout local Components = ctx.Components local WidgetUI = {} @@ -25,15 +24,13 @@ end WidgetUI.breakpoints = { wideW = 560, topBarW = 200, - sixthH = 74, quarterH = 104, thirdH = 146, halfH = 154, } WidgetUI.fonts = { - sixth = { hero = BOLD }, - quarter = { hero = BOLD }, + compact = { hero = BOLD }, third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = STDSIZE, detail = 0 }, } @@ -44,178 +41,17 @@ WidgetUI.fonts = { local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). ---- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.28) - local c2w = math.floor(w * 0.40) - local c3w = w - c1w - c2w - local columns = { - { - type = lvgl.BOX, - w = c1w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = WidgetUI.fonts.sixth.hero, - color = Display.heroColor, - text = Display.heroText, - }, - }, - }, - { - type = lvgl.BOX, - w = c2w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = c3w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.row(w, h, opa, columns) -end - ---- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. ---- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.30) - local rows = { - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.hero, - color = Display.heroColor, - text = Display.heroText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - borderPad = 0, - flexPad = lvgl.PAD_TINY, - children = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.column(w, h, opa, rows) +--- 1/4 and 1/6: one row of readings over the headroom bar. The composition +--- is shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) end ---- 1/3: title + hero LQ + Range/RSSI detail. ---- HD has plenty of room for a title row. +--- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. function WidgetUI.buildThird(w, h, opa) - local rows = {} - -- Title row - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.third), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - - WidgetLayout.column(w, h, opa, rows) -end - ---- Data rows shared by the 1/2 and 1/1 tiers: ---- LQ, Range/RSSI, RF mode/power, battery. -local function appendDataRows(rows) - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.full), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.batteryText, - } + local m = measured() + Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.mid }) end --- 1/2: the panel without the group rows or the rules. @@ -246,10 +82,8 @@ function WidgetUI.build(wgtZone, opts) local bp = WidgetUI.breakpoints if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then - WidgetUI.buildSixth(w, h, opa) elseif h < bp.quarterH then - WidgetUI.buildQuarter(w, h, opa) + WidgetUI.buildCompact(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) elseif h < bp.halfH then diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 2c8a019..a37ab6e 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -6,7 +6,6 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity -local WidgetLayout = ctx.WidgetLayout local Components = ctx.Components local WidgetUI = {} @@ -25,14 +24,12 @@ end WidgetUI.breakpoints = { wideW = 240, topBarW = 80, - sixthH = 55, quarterH = 78, thirdH = 110, } WidgetUI.fonts = { - sixth = { hero = BOLD }, - quarter = { hero = BOLD }, + compact = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -43,153 +40,17 @@ WidgetUI.fonts = { local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/6: single line — LQ (bold) + Range/dBm (colored). ---- Portrait is narrower so skip RF mode/power. ---- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.35) - local c2w = w - c1w - local columns = { - { - type = lvgl.BOX, - w = c1w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = WidgetUI.fonts.sixth.hero, - color = Display.heroColor, - text = Display.heroText, - }, - }, - }, - { - type = lvgl.BOX, - w = c2w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - } - WidgetLayout.row(w, h, opa, columns) -end - ---- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. ---- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.35) - local rows = { - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.hero, - color = Display.heroColor, - text = Display.heroText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - } - WidgetLayout.column(w, h, opa, rows) +--- 1/4 and 1/6: one row of readings over the headroom bar. The composition +--- is shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) end ---- 1/3: title + hero LQ + Range/RSSI detail. +--- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. function WidgetUI.buildThird(w, h, opa) - local rows = {} - -- Title row - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.hero, - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - - WidgetLayout.column(w, h, opa, rows) -end - ---- Data rows for the 1/1 tier (portrait has no 1/2 tier): ---- LQ, Range/RSSI, RF mode/power, battery. -local function appendDataRows(rows) - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.full), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.batteryText, - } + local m = measured() + Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) end --- 1/1: the uplink panel between a status strip and the group rows. @@ -212,10 +73,8 @@ function WidgetUI.build(wgtZone, opts) local bp = WidgetUI.breakpoints if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then - WidgetUI.buildSixth(w, h, opa) elseif h < bp.quarterH then - WidgetUI.buildQuarter(w, h, opa) + WidgetUI.buildCompact(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) else diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 7ff24e9..759bdd3 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -6,7 +6,6 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity -local WidgetLayout = ctx.WidgetLayout local Components = ctx.Components local WidgetUI = {} @@ -26,7 +25,6 @@ end -- screen and the 1/1 layout drops to one column of groups. WidgetUI.breakpoints = { topBarW = 100, - sixthH = 37, quarterH = 52, thirdH = 73, halfH = 108, @@ -34,8 +32,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - sixth = { hero = BOLD }, - quarter = { hero = BOLD }, + compact = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -46,170 +43,17 @@ WidgetUI.fonts = { local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). ---- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.28) - local c2w = math.floor(w * 0.40) - local c3w = w - c1w - c2w - local columns = { - { - type = lvgl.BOX, - w = c1w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = WidgetUI.fonts.sixth.hero, - color = Display.heroColor, - text = Display.heroText, - }, - }, - }, - { - type = lvgl.BOX, - w = c2w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = c3w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.row(w, h, opa, columns) -end - ---- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. ---- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.30) - local rows = { - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.hero, - color = Display.heroColor, - text = Display.heroText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.column(w, h, opa, rows) +--- 1/4 and 1/6: one row of readings over the headroom bar. The composition +--- is shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) end ---- 1/3: hero LQ + Range/RSSI detail. No title on SD. +--- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. function WidgetUI.buildThird(w, h, opa) - local rows = {} - -- No title row on 480x272 — too tight - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.hero, - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - - WidgetLayout.column(w, h, opa, rows) -end - ---- Data rows shared by the 1/2 and 1/1 tiers: ---- LQ, Range/RSSI, RF mode/power, battery. -local function appendDataRows(rows) - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.full), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.batteryText, - } + local m = measured() + Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) end --- 1/2: the panel without the group rows or the rules. @@ -240,10 +84,8 @@ function WidgetUI.build(wgtZone, opts) local bp = WidgetUI.breakpoints if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then - WidgetUI.buildSixth(w, h, opa) elseif h < bp.quarterH then - WidgetUI.buildQuarter(w, h, opa) + WidgetUI.buildCompact(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) elseif h < bp.halfH then diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index ecbcd73..cdd5ff6 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -6,7 +6,6 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity -local WidgetLayout = ctx.WidgetLayout local Components = ctx.Components local WidgetUI = {} @@ -26,15 +25,13 @@ end WidgetUI.breakpoints = { wideW = 340, topBarW = 100, - sixthH = 44, quarterH = 62, thirdH = 86, halfH = 108, } WidgetUI.fonts = { - sixth = { hero = BOLD }, - quarter = { hero = BOLD }, + compact = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -45,178 +42,17 @@ WidgetUI.fonts = { local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). ---- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.28) - local c2w = math.floor(w * 0.40) - local c3w = w - c1w - c2w - local columns = { - { - type = lvgl.BOX, - w = c1w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = WidgetUI.fonts.sixth.hero, - color = Display.heroColor, - text = Display.heroText, - }, - }, - }, - { - type = lvgl.BOX, - w = c2w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = c3w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.row(w, h, opa, columns) -end - ---- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. ---- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.30) - local rows = { - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.hero, - color = Display.heroColor, - text = Display.heroText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.column(w, h, opa, rows) +--- 1/4 and 1/6: one row of readings over the headroom bar. The composition +--- is shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) end ---- 1/3: title + hero LQ + Range/RSSI detail. ---- 480x320 has enough room for a title row at 1/3. +--- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. function WidgetUI.buildThird(w, h, opa) - local rows = {} - -- Title row — 480x320 has more vertical room - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "ExpressLRS", - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.hero, - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - - WidgetLayout.column(w, h, opa, rows) -end - ---- Data rows shared by the 1/2 and 1/1 tiers: ---- LQ, Range/RSSI, RF mode/power, battery. -local function appendDataRows(rows) - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.full), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.batteryText, - } + local m = measured() + Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) end --- 1/2: the panel without the group rows or the rules. @@ -247,10 +83,8 @@ function WidgetUI.build(wgtZone, opts) local bp = WidgetUI.breakpoints if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then - WidgetUI.buildSixth(w, h, opa) elseif h < bp.quarterH then - WidgetUI.buildQuarter(w, h, opa) + WidgetUI.buildCompact(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) elseif h < bp.halfH then diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 5d1a665..c9b4c62 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -6,7 +6,6 @@ local ctx = ... local Display = ctx.Display local bgOpacity = ctx.bgOpacity -local WidgetLayout = ctx.WidgetLayout local Components = ctx.Components local WidgetUI = {} @@ -26,15 +25,13 @@ end WidgetUI.breakpoints = { wideW = 240, topBarW = 80, - sixthH = 30, quarterH = 42, thirdH = 58, halfH = 82, } WidgetUI.fonts = { - sixth = { hero = BOLD }, - quarter = { hero = BOLD }, + compact = { hero = BOLD }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = BOLD, detail = SMLSIZE }, } @@ -45,170 +42,17 @@ WidgetUI.fonts = { local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/6: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). ---- Fixed-width columns prevent layout jumping when digit counts change. -function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.28) - local c2w = math.floor(w * 0.40) - local c3w = w - c1w - c2w - local columns = { - { - type = lvgl.BOX, - w = c1w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = WidgetUI.fonts.sixth.hero, - color = Display.heroColor, - text = Display.heroText, - }, - }, - }, - { - type = lvgl.BOX, - w = c2w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = c3w, - h = lvgl.UI_ELEMENT_HEIGHT, - children = { - { - type = lvgl.LABEL, - y = lvgl.PAD_SMALL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfModeText, - }, - }, - }, - } - WidgetLayout.row(w, h, opa, columns) -end - ---- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. ---- Fixed-width first column prevents layout jumping when digit counts change. -function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.30) - local rows = { - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.hero, - color = Display.heroColor, - text = Display.heroText, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }, - }, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - children = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - }, - }, - }, - } - WidgetLayout.column(w, h, opa, rows) +--- 1/4 and 1/6: one row of readings over the headroom bar. The composition +--- is shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) end ---- 1/3: hero LQ + Range/RSSI detail + RF mode. No title on small screen. +--- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. function WidgetUI.buildThird(w, h, opa) - local rows = {} - -- No title row — too tight on 320x240 - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.third), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.third.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - - WidgetLayout.column(w, h, opa, rows) -end - ---- Data rows shared by the 1/2 and 1/1 tiers: ---- LQ, Range/RSSI, RF mode/power, battery. -local function appendDataRows(rows) - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = Display.heroFont(WidgetUI.fonts.full), - color = Display.heroColor, - text = Display.heroText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = WidgetUI.fonts.full.detail, - color = Display.detailColor, - text = Display.signalText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - } - rows[#rows + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.batteryText, - } + local m = measured() + Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) end --- 1/2: the panel without the group rows or the rules. @@ -239,10 +83,8 @@ function WidgetUI.build(wgtZone, opts) local bp = WidgetUI.breakpoints if w < bp.topBarW then TopBarUI.build(w, h) - elseif h < bp.sixthH then - WidgetUI.buildSixth(w, h, opa) elseif h < bp.quarterH then - WidgetUI.buildQuarter(w, h, opa) + WidgetUI.buildCompact(w, h, opa) elseif h < bp.thirdH then WidgetUI.buildThird(w, h, opa) elseif h < bp.halfH then From aa77b7d65beaf4ea971e7dc74485932acfe0a52b Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 15:55:41 +0300 Subject: [PATCH 213/218] update screenshots --- screenshots/tool_bind.png | Bin 25261 -> 29885 bytes screenshots/tool_bind_bw.png | Bin 4138 -> 4552 bytes screenshots/widget_telemetry_fullscren.png | Bin 31044 -> 29809 bytes screenshots/widget_vtxadmin_fullscreen.png | Bin 26645 -> 21644 bytes screenshots/widgets.png | Bin 22713 -> 72796 bytes 5 files changed, 0 insertions(+), 0 deletions(-) diff --git a/screenshots/tool_bind.png b/screenshots/tool_bind.png index 5083b31e7eb9c018ce2bd406c779b013e5935859..8aeb6f8753df948c18d8595ddfd3c104830c073b 100644 GIT binary patch literal 29885 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 delta 15865 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 B8W8{h literal 31044 zcmd43byQXD+cmmq1eB7LE-68f6zP%{kS<9Hk?uyiK|neMq&q~qL%O6(y1N9zdb`b3MUt8Tiiw>(?4~5cH@6_75(O>CqDiqJX5tgx@-+?9O|cYF#u6-faljTjP-Mpgnrs z`b;46Q;&npZxbv&OQ~4o{aE8FTkUVD7zS=(llvl!8*KtLYPOb({kL?Qn+CPJe6!1g zORIX8jU2PG7%bbv_bigI)0+4{@RE%B$b(rX6_Byr5Qp==y-W> zA|UxOcu4u+V(0cQ?NV^@4|-(%0mo7$#o@f`A}TiQ$N0o+@&-9%g#oTibLxgA2=-%& z7bywBd83&%9(ezKL-ptqF^V1i4PDWN4i@ZFiujQV5F8Jhh#2^(YUS63>o7-1(>zF~ zpaMTxvSg*uaV!*DNtpWge%Y=K@Te_j>af2Sd-7|QTVG0>Q9^Hcay-0Iq~o{OWa-Sl z^d^g@AV%?)W@Ko`5cAzTY`z(H-dgVLxkzdrJpT(X>Y%~o``RkCphJ?Hc8zDP6~?$L znD}g0Di}=)c!>M-7Ot$YyPsOW@`OVimJ~Mmo#o)fA9JP8mGw>h$KYq?tM?ypk;Y$h zRgqL>ti8kAzHi<^mG4%Xsl81mZ47sSye~hA`^C{S8^b^1r6I9>T}P&9eX3pd^}q)< z2i0102%8(O1Px9p@#@Hj=wW=Gch~!YFD(;I2vjG;z9VZ>B(vW2wr~akDs~8V#8ek& z%|B>wXMB58>1t&ZKO($IfpEOadQheVna74Mcgr=aNQ&B)v<73b@!b4P{UUQ05tSwQ zKCHpBG_7_}2Ko&mqg;1UVctK4Ex6)f1v1t$1I48k)z@dq>@`P268gB_5gX4mSjX{0 z+AngDr^Z?~J+&rl`b0>gZ-|lg@D*8&n;d1wW^)!VM+Y~{)vwuHjqJnxmgbB#1_F+2Kwm6OxTc0*0(}t+&!3l$r<-Li*_x;~$Y#57m$z1aci#45(?-=uLgW*R0Ij4PPnvU3bH`kzIgquwAi;&lf6y4qGOzT&6A|BA>>l2+=J>jB%~f} za9l_Tjukk5uV>rLsNwWWXHjvdsL?*G*xq*ZnaN#MJ>wF7O%8|dMJkG3FkCV8=%6}i z%gaDM88-Ntgb*b^-P(tnUQ!%QrQhp?(JGlfw5a81Jv3WVUttGM);R{xm zS&M7gwZq+*4h+H})pM1y-iq0`wboowPs{&qwK|vS!^G_(Q`APhS67W~D=K+!H#l_y zf_oBR=PyM=j$Bs-9*lCyXl6Q6b)1ryc1Gm54dhvtKY+LH$yBH%PS<6jI+mk1ohBC# zH)_|hqHdU?B20?n9TkR!A8HS;_lN5{I*g{2YriS7(F~6~g=* zrgy=AhQm)$HulR=w=D|a=xN1G-cMUB#~#8#12=n+H_M_?U-@Q3KuI|ka#E`4w3TFc z(88B0Im}Q>-lS0lur`@boG9a;Dd_Zp*`UI|xhTSWAGwpwuO{L&7Qe!OsM~^S4idqp zWs#_Qa2vT2p#&`Oxyl+Ogtl|WMNT646vv1oMCuq3f5nT)KF3EHAAkBf&l1;rEowDA zoZ43#S&f)MMro8ILyEY?SBtLh2cwUwYeX9+MeeMS) zpL8*+*esd;QI&dncvd~IX8B#$3;{SKRGNY=NKuEk<0963>EexFiuSvd(I=}9Y~8a; zqr9)$)I=DKh!!HEvF6<3(Oc17u$uVtL}pBUx5ACsrd zZ8XStZyRpk!4}SR&-M{T=)HD_&%I9bOvL%D3x4L)*g8VeVH91Q81g^lHB6a5h$8dR z^A|DyNOcYSVO)?skpF>qR;|MGI|@wgi>i8>!mLOUhKnyGc7Kl#a&lQ5I+2rz9>dGh zOhX#K43hRu))cT`h7ctzO5WWKxh@JxsaGEGDSf!@w$Puh=uMEAm|p7n5m|uNcurMN za(77Tl@TfH5Gg)1In|DirMcMEC0m^|R!)fU!Fz!?_ERqv&P`fB$y&60G7;Gy>s z6FDTfh)a`Z1T1OxCa(m++IZ5{9FHOi{ay5VTpXby+8?o%n&gzPi$3Ccl?6q#a}tsJ zbw1lFo$W(1)ST$^wR_QO=7HGOsMWVc#+qioGHIr!X?-Y1kILj){{1b>`g~0S@*NE| zNP~eSIJYElhms&{JEE?N4Sz19B&8ol^rwUz$UNjqU&u$O#Hl8mLv z@GjV+U%*Y1N2-dnKH5cEh5dljBS{hDx0H!|{eBJ0&PE@Jx|p`$+-KXJGY_j7EG^gI z&1pQUUTZMU4T12X^>n-ylp#er(jBvKl~8y&w!jKuH03N7+6tzfoIZoC3}bi^Cq+fU zlqZej3#)Ri(HRU6JUO?4A|0bG;pgm_#SZ2pcR1rQ%ydC7sRO~#+6jGsH;;~b~7$+21T&QOsRvj>Czi>3!iq=Bp7qCa#sF?tKUq_V^vu`NSW zySv4Ty|I}~Ro2J-BC7bOl`5%@h!X73NWf=KZ!rR&$)UGZyNE^bWxZdk&r|PmUa89p zqEeJ>EoWb#-!eD|O;VuQjK`su#pTqf;Cqjha`asHm7Ctm`sWdsg|1IUY@94Ret+mJ zjf|$w#dB0<*+qv2LajwNQxFT`I%1=D#)~_+_r6FH?A0Hq*jRl6DR59HE4w zGATNj_A-dCT~!U7z2|aY-7jErqqWxw*+g2idN~>okr96xsaSlp!IEsl4~mM~Mnd|!XB&)L32SmW;u}!`@>>Rk-8j-?9*FcthQ#Bz` z4G=~zaSB(9c?C^z0h~|i3$M32wGY2N9yn+%HNCR*Q`et=4(A8UADdFK{NAyNQ+B_p z33WQ6iiDk|TZD>T^ak(4P3Be&4{1k!QSwh4*<&}co}?Ilrk7yN!2kQfpve-W{zK%Z zNvyD>sbJ@V0kv=D<>k6OUUTpk`vUV5&fH1%2kdJ4}N z`?LRU$fHd-nvL7VVAw#uB}fwsQC;{jNza}B>Qju8%_awFjoJMa0ZPO1J|4A{O^{kt zM~qK@R1W>cyJ_@=$WZ_s<$A1*IogOvJ716vR7Hv^SV>Q-KOfr+1SPQV)5N|j9^Uso zLU|Z!73++1U{`vM_MtgeV8(h!QfQ#&bR;+%m(h3Y1}8U&1s>rA@yj#?!DhQJL89E_ zVcFzIo#LnRc6KPR70!}Wk;;2V>)bqy#W`J9HdymOUTZza3&n)vTO!Ik+wL6azw?;v ztVhJ!<1-@ZC3KhaGh)BS_9zR2`;MATzG&9s{$cO-AGBYXr1gfp4`d!L-)yxL#mlYt z@1M(WeBXLUS5E&X%oDaELG)HT_(I-u7squ^Sl^Ac${G|t&j0)A5z)#LI2uBv60b&A z#qOl|dAS;_4VKNnbKEhVul>D~tYoukc4DRmVTG|!9Xdp_#2X&D%r;MtfP`;X3u^Jm zwLX2Rmd#((_+EGe2bTW$3nux!dHM#_8l)4#@XWR+oQSddi!Zekr>yy7h*b+1#a_=c z2MMZo{8r1q6^ngp_jV2BYjc_>+jj?eJ$4G~Lw_5YV=y=|XSB_NK{BBkM>N9g0@VJ4 z!3|w^vbt;v>!$!9N{Iid#j_8y?(l_8^BdNVCc@}h!&ShiUgl)3<=y>VO4_G(%mT2p zPo-(K!v$|Ji^%c*m$h|{YcKqw+K7si#?Uvv&W&?4?6h`Aoc2BjM{It<0fY@;co`0WeWD(gcqtP&0QrIH`^i=?q729XEz3%jXdCEU z3Y;XdQ>Tub&Ou2JrD$oC#AE>5n#lqIe8X@NNB>)GzdQ41VGye+s@P-D1^K!~#^MXj z{h)bP)<4GAdJc5m>DKoNVE9 zXa%TD{|#QHCk^&QZm^Ija~dh2&mJGaser*b&`%(E#e|;|D7*wnIn$+i9u@COo6ja< zRRbLW&sI0N!OJF9{BJ0EhF~xJjQi<+F?GZm?rZXUUkVI(RKd(*IzmEUtk~BwUjmR~A$$L|**IUW{Dv^; z^+PBL8DoAJArRC^90BOvWq?52yVA19XLo8Z-3Hu~riT4I1%q4QHZvL6#F=??lCYUz z$BeNt1|_PC9QE6@Ng5T-lSkKhu*mG!wFD?TW!jKTm%Y#CAhNn(FE8;-D}(zvc{=OvwfE7XKa{t`&u-!o!Yh-;N?{XB zL!?dhjkBDdA7NdM`Yerlo%>dsH-YT!BmTw*C3zEbTu;Y2-ZgP_Zj5r>F27O~v+Poe zZ`^Dm^mtqlI~Bk2mi}TjszxKsOnskR6VCb?4^|XcOTNOf{if~Z;8SH`&f;#H@}vYNN~syuJ72jsf+=`^x5{R z;&(@T-50s~4qW5V(kW~svYrYHQ&WiQnW6s75uEEM>U-vw2N?^uYkbbYPMoTc&)H5M z#hUmj8uXSS5PKG+0E(wbbu|Z_Kv|h?Mjz))opkTK&&ZdR7U9q^nMNi{}#!U2g*~k{Sk`{<2Cr z$IcVp?s;%0+EQlJ0DoVz>O}R4gLK*!H$3mFIt=^vEXlD|PI#P9_`Ou9TzqrOM5_I=^ydJu15kcoDriAq?=FvzQkTT za9ec0iR{ip{)ZE|s=5sk|6P(jz@UH~MzOc-|9ufTCI7F!Rk290K1^;}I5n~ByC8vs zwIozoG5UXdlm8VLfN(sDyeeBwEdJ)AkJ6?b?R%3CE^4gi!}heeshwYxP0qr`0GJ&5 zXWsTHrrf?l7AGSre1tp){xuntTm+vE+J;5H_lnS0qP>)_%0o;}B5=&OX#Cir;?QUn z3Cjta!By+nQMWfbU5h)q1zHwP;>-L~fsLF)t=#NkzqEfk^K%ijqii%ff9yW5Qj9-* z+kwi+mo{CWyF*aP`uDrLdzCHrGIs1SwM&2KV~f9zQ)1gUlk??9I0UrHBDyhrN@;zV z;fyab#B<$+{bz51yDVs>e(veto%w7!0EY;fJMxB%$SGdB3#=ZToAZ72I3`%VsVi9N zByc38G+Ig_(8+U?_Kf_yF$x8o3a#dr0_L*Z$7xz*F29a`s;-{Z6=Z>FWs_yE`;#(Q zR&IHe&ThWHnH(s@ukW6yJG%J2r4PNs7OzS@ZPC>CuWn1SEfsX!=WLq7=NQ{k?mf^4 zf6H^&7fM+FJZjXWx+%tsg=tad_~xw%-l~JKs<6e0;_4jf&~Nu0)L?pzjO#Y zd_@vM>G;>}VKhh{o1dUB9i1iYamEX7PYF?sd$6`t_TkXHD?qGvaPljv%}lsVrPTS?|K z)5~(ZgMw_NPwfQDTe^>EMH0R579SC3u8jZKlejEXn;lu@|5UqpM7W1QeiNQ`K5^Zr zwxh5U-poyA(bXBrl6QLWgh+Snxcl$Q=e&)9M!^S|?sj-?JiVml0%|f6S%M4Qi?Y4hnxouCVM*1N=6--gwllx`dmNEO zC{wm&>3}tR%{f`u{XS{dRPf4-KCkIG?$x&K`K>39o%h z_Ly4r7KV=$FK6>0`_hv`KR=}$C-h2T_;KM5`TxB25!&0v)4U+aid>agD{+Dl&%$+H69lrmOo4`Q)=K+O32BRtgt1_2bvl$0gfB>-CYc9>tG?Ws`KDQaZoz1|icK z@xCT&)$i`k!jX50cRCSgm`cwYj~RBNT{(>AQE0$fC?1fMi{#|OZ>ktG7Yn_E1TSy; ze`%eNKpy#8_mS>}_}aCpr-an_PJmbM4cKuDi8Bb@&eFh3Nf(5bW#U zGC#3JQ>`c&gP8g%{lE*u^P?;)!f8$btEWBhS~a4RC^hU{<*;VT3hg!B zTFfj(!773&zxzS5)%{WFS-qz}-6x$H=a}qTj#0olO~H>Nxa=bB2{I zvIZjWnhd-(O);_9WS?Yg*})D94RLMv?gui7Wb`ZO#1SbEz-kcI5lIo{4|CiungcGk z(Q49UPp~P2XnD1d;pr~kWbED3_XenQic2%kjCTS6wI0Pe;ho~qOg@RmRE6}gHQC2t zUKwp?lr?z-T~uTC%DpM}95k(mSAr@K^geQe5KuTko_iH|HjI5~$FW92TZ0OfS!#Kl zQa{hRIG8i$Y?I8oA4G2Sx=)YeY(M}jTg^$3Ikj7Y0MY9Srs#p**YQ{3UeG`%R$53x zO>iM2JoybJ2$klA*z(fDw}>Folmk5VtI6Sox$ZBYg^T)1U9ZhH2=)SCD#Pvo%)_HRrxN(3iORmzo}fe zz{2L=LPMjlQ4#+=Nb_>R5+#H6^AB4FU1%7eWBdnZ;`zwAqgGd3<s~5^!CD5M;?xbt+R0NtEArfMVqxWB!%do%Y8P3c_!@YAz`h} z8s*fS)Nfw%Rki3GI41xe-arMU7W=rYZ& zLv*ywLW92Xpv|C&T}g9EkXHC(X39R-Nvp+D%0UDu?YJrobP0sU&qIcuuv_uqC5IAu zB$TUccZQFA73rE7?6nUE>DKsza)>Jt;FoEJI*0H0ZrkHqzZ$#P=`-L00bAUfF`ze* z{_7VsObTD`qMJ`NTvR_zPG1mxe!=z&B)m1>;2?S@q&);2HI-MjhI{}kzcGnRAsI=XTd{QX0>UptE7+Hh)vB3!dD@S>W|uIv$sf6wWiq;7zsI zULZt^EH)wji-|p5E$lkdsIS3%S8(*c@>G!oWEgE>P6~WQzq}u~3uyxO4YRsc1ITJ) zwheNa6x8i74CgN6KwvWVHAaJ}2qYf%0NN|`Myyd)vw^FWIDq12d;p`!pe(#9(yF!LyZjLUU#C*akyc3n%oGatcn5w)y0?l~>p zcd?Ew$9HtW*U=L++Zyc8Mr6cJdH8?<;9EgvWox;4aR;aq3l?<3eaepjyEv;*HFCv(C(70a}j&gHH?Qzm3=4Pd!H&b%h znhdw=Y{K-%5aX%X>8082b5z>gti3W)Io9dQnz%mk*Q(gkvYjmh<6e=CYG*2CG2C4C zd+`*gLe`8Xh>wKGZKPzQZSTvRvj=_n&;Xr_3U9qmn5PR^WDJWLl&@>fPml*)C>8n- zBd;p(eF-1OBX z27rUQ?)oTmrQ9NqpMg}&i8iQKqaZ882RhhQ;y$$so6-pKilFEqGFGri2`MA@awZ8XsmR5~f;PAJk)YFa`{FAT+ljijC;BzN zE$IMwIqY8A_%>fl4gd`38G>Z0nU|XvUg&6E?B3i04Cq520;;{?C8%zWQ_ClZi?XH5 z0Dn7%*AYvumYe)pE4@&EA|e)<+9AKzGDZXnaD1PKWhBeIp>-Rh^^B`tESuT z=E)m67@iE;9N}{9ZCI$^8W=~)o_q!Wm;DioW%_GE0Rcwzf9gddKy9z^TF;AMS+CaZDj$#L3FJ9u;CM%Ss;gJw|ykOSilWeA`Flo&wg8wSUJIlm|AhQ-h8XCMF- z0B@Q0@t2&v2MIv2$BxZj2W@%S3#p<8U7iU@{y_z}0&i0fms{LCG3I{$v9c4`19;A6 ziC?fl{?dB089r*mz`_-@k#DudN#TOU=)T-x;8o80IuD&K1JH}=z8hFjP^PM$SpWk& zM}`}^;5Wq=&)|Fhyx;?r1}G#a_5QR}S=u1^V9bRrfiD_Qf6(To<&$ehG#lwjmwoF* z`48}o?^RKim=V^|WuLV#CC#Ah)5BZq{knz6*y$lxU@mCYTjeh#QoIz-rfJ(({Bh{KrS5fnu9eQ8O+~#2b5+Fkg=NIo}pUr?5+w*}LKN&S#wecH> zo)7kOJ?j-*|5TF!<7xnpkyry8BVaKsg+&k7kDodPyKyF`aoX!s7#4R8`}@RRI4s2x zuz}Zk7uCn_YyJaKL8ky6f(>2Wn6k;@U*)H-46*T`%2JB%6W6}a=_k}t5C2n*27SBr zdA64T{0W_?LtSVeSyAB)whc73^h579Hs7wq_@IY6a4W`#K#ts^4G)87CfrWrB!vU;#foi+PbqSDSct{+qNhcBF?h9D= z8agaWrsgc1xj|2&Mn&>Vto5{x0_t6(zN&HBuOb?&08K_c%hs7giB1e#YN2sDsS>Yb zYH-k`jG~25MRcC=sq;_{$6v~q z@y*6QjG1+JWO~W_Elbv|2o{&373jTo@{Fh{08=z3A~AM^59mJH3ktpSmwa6dY8@xKe-?YT9dSvW%nL9q`#)w##aBtcM5a z6tCYi;3|duL+n+nvfiW=d|E^b7?SZTdheg<_W3pwrp@>O zlP|jI9O_MkE|S+I={!>Z3ypws6kJaL(-ovQ8VgvpN5|d-Uitn&Y+I@>E?_M>K`kEC*Se11sav5J@Dpz>y=jN#qpt7JZI{)W5bZ^IV5pxQKDPg zyrO8UWJ}>!4F%B;nt2{h_QZo(3aTv5UuAF|x|ZF54un^z6*w#K%*XQJEFs^*T>hMH za_=AG?5t44tP<38zsa?&j2y_C1yIM_!*H1!*Hd>%#0}-^;{L-A6`X`X4PU*{7 z@IF5c>4yP3<3f?y(s4GN03*DS(-_je1~dSFYBTXm=5at`%U;Xi-{}elsm=Wfm-p^s zDGCB0m%v(>seEU)NTz>_DHoQvdlb#=`~q^(1SIg+FyarI*IzB!+F9%4aFBL`-Q?oc z!*}$f7-l`=nW2#X(1S>h4?ks6GS?y|cYb|VV7g`Cbzd6!%&2Q) zjDw5#Glp{4W%p$!DjaM@Pp%J;lp}~45DfrHvJp>#>}FT8+G`CqCAL@fiYpf(f6sUs$4V@~u>3uVYUQWNd za9E)p_6T)HwU#dh)S91wO#u@BBB{J)#E1Cw?bh>)lLRojc4V|8iUnQ_rTB4m>oowA98nB879z)o!+JrxUkWM=IZ zJWdkf&}B|3&zUu^oX?SP{(yZuXal@|^l*-{ffD-6J@GvzX zvEhsWdE#q$JairM7DnwoP662qBn$$<`mmCHUBH6yDr{)>+EtAG!k#aJ^a^6bs60UI zWl08G)cW0}Ph@?}!vn(w5U&gse&*3E&@Jr-_Hbj`CEGfkQohZa`Cn)vE?8jKIZAB` zrhUf*NUiC><77|puNEi`Vi4pMp${y`7XEl@ch*d$seRlr2&E0EE}C83i+6^NR)ARm z1c-I^mjF3EE7wZiwHwJS~E_&9^z%SUdtLAe1BE9zIj|CAsn*mLu@q_lB-_0?BV&dq?>3r zOc-Z+YUH+(`;LDb2Wbl=Up5|ho74k9MgdccpaQV3W_MAAz(jzl8!p(JL1A{5{4dO%cehN$NH&W0FX6WZ-M3A1c93xKurXQrVurm{+%sdfR6<`yjR2uP=Ymv zFy}NLv0!S=53VgDDXM(JQC@B?rg)yS)W<;Z)wZ1GLRjpSriZ~B)!29IlUH}JBj8g< z05rL{yDL6&b!Id`3{_|e_-;~6||*;Xmbq!=KzZ8i}Mjqe8%2JG=Pc?o5G$me|tM<$uPkqP`{Ar!Q zmK5^7sgR?jf-X2H%an~@5`;H{j*W`H_+#{!Psx>XIy5n_#Tg*%OEzc`^N!IWz;Y8dQBoI}V<7^LTV$+%y*gw%i0I?E8>esP1ESQ2kJJ(2 z5V(y19psyV!ZNf>{CbTFFh>njr-ni+>Ug^;*D_)Xb)2QXczS4$dUt=EQl0^&GpMj& z%SG+aluf7CJSvWV3V=dSl;v{n^py-~Xc8Mvg+A6Mc`bnoh+OsxJ~|Ri^)y@@OQkxe z9vAy=7AEw$5h8vaE5^nkgd46fB%+k zFA}u!ifp=P=}MD|Uy|X-27`R^SR)pf$9cA-$hf;y!M}R5@P!Z*gh6O zgCU}qD_>r4>ocG}&FDfj-@)FnJWZHYfP9}Pt=Y3lmh6`c2iiw`d%@$PJK?z5uM48; zIfpbZM7q!$1IDa^fTnU(n!-+d4vAke3z4x2s@jPQ3pH;saaF2|E=0sm>zGW*vDTP~ z0LC=G*c&1gzySpW7y^bJ>QO)u^0Cu}h5jBdl8OqTH#B1HfUU3Ucd5!4{88?gU6AC! zqEVo>ob8W$aDWGp3`|7=%8SzUU+ye)h!KFNb)KgR0o-ZTl-ekM6=y0eRKH*&8-(U|f2n!E))r*xr7Gw^njFc(>iDn;tLh zd|YQ+3&N&Ob_7@wLy71NcOe&7*c8`H5p;B`8&tyvi|i)pxJx@PNNA&}RCAK}VIM^w1J;8u`$G4YNZGZCL+3^+@O0a{&@^+{LWtRKn3sv z@&^1*`CJI__5eW8dFc#+uGEA zsu&*#22)Fc5I3*;NoW37cx=96_f{y%5-hPANX3HuzF-_cJK&NaL*so)zF_A8U>i^o zy4M8fe8tDT5<67zP;~QsfV2Q?DZu&Sasiw-`M!{s?+{|#$7DY8FEa6o`gJa^p*6b2`Km>ZvqAK2jiHIyIr!0aT&Wg=R`Sb!+g z#ne7SfQTbG^W>6a7->PEvO`OsP8K**KSu(I81jD6OvKTK0;!8(>&!PYTmVmm#S80I z7O2JUS2PY4a43WFILy)Fq$SV4VbTHMTX*V?!1nO;8w4TpYEVK~dR@>|={SGvU?rT0 zUTL!FDo^l0LXV&2ZoH0*Vas7EDFAA_@W$_cp#BfANY3oU36^cl6M)BN>4xac1wNQ{ z&k4o?LWT0!n8exP%Hclm`XD+jpq^954M4+oZX+P)`9h#YGIgD9P-T0f_`LM?wd}K< z57sSIs1o6!6g1}Ar_ss4EpSl%XGafM3!E$-Q5@wgMg45*e-s9RrWv>u42kho<+m7r zr?~swyRxp9PqUsWER#gc_}&27#n?s;;oTh;tHv2-5)S6Nw=NBt3MY@#7Ul-P&r!`0 zqK~;U_fb^GT(MsOEdpSL_>$~VcX#d zABzgzb^|7dQtnp_AdYT4grzk7X0Z3JXn`RF)klZ9rbFq0x{jY+HN zf2SA_sV7YWiES-YFBG5}kj2C}AF`?V=P;k)-iy&o`8|dbv|WFx84#a4=E?`2El#R+ zXcj*bO$+^N6Ubv5EvqQ=;5~J}0?-cNyb-52dgYSCt=HY8?70?m2_^6#;l><7_Oy+3e`Zp_Z!T>K?>+D$IhU15B zA2nUS3vKe1f-6=ZkwFcngYN=>qXZ~RF7G{PJMPTCMq+ ztuy8QHZ^%=#nfd17IdKy8$)}nxt2z5Xq0TzOIj8Clhw0G#$QZDoA-&jxH0OZ+@Hnf zgJB4)=gm+wped4$;}RSvxbc11t?u9w%4lyn&CJFZK33S5$;}}O>8*bOs~&%X`Wj|k z`QoreAPO#s+9hE+8Zdmec{K(OwR?#IrWX>b-FV!vaUJ3)-_)jz4P%(0CF#+M1^}O) zTv;@(Q(iHh<_N?|GZKX7%K#bN&`94@M514SKZPdHx5K_j;`8CVQuRlO$J*<7`8b&8 zSCeWq@yl{|^$s)Ppe_QKc>Tg>0(AdbDR@)g=9~k@Qmj7r^ z(8|B|{h0?h8t;2f01#pHFa)~QwUmSOprH6OP-qvpU$8E~xHktrOQ6pQDXvC|q0h0( zyfLtO2XFnBrPMWn@S!5-$p~Cy6kou%dQ>-Y#YG7T&6?6y^FKN3>TXhHxMs2cs(BX zIwe;|>@A1I%!9@gdrW9)oK;g<*NSfoJgs|IC14zQ>NmNb4V{L=;f|$)BBFP;FB+9+ z40!Dk+lJx{Aeel6-fGtQEpOBEb!t6rTxb^=1;|hg-9Y1nYvBQdX=Q}G0m*cPN3QNt z>VML-2%JZTzjG#yM8yFe^E2rj&Wv{<03du2zrb~CC*Q%E9#A^Y=;*Dz;)YkiIBD76-iv&KfBIVq>l$X#p>LD&fEb-lzj& zlp5lzuafDD>P71t+t7f!33Q^5PA`{dXj`dJQDqr4)Y1TV8yR8Ld*ODj{*2jpT5SY4 zujl>)1z{XvBfky<<~~4!lB-AfFj9tx>^a$8F`g&HHALCj=D0V$Dk8u9m0qs1 zHik3z5J?9-hol?r2w>WEa|rTmjPiTv+P+f*ihk@7N`L$ z6RnK8=~0P)o+pm67!{tc3uH9HSK6OM`4iPDlTxH~bY!qjvA@)OQqjosfF--8i-Fqv zOYry}BFt}%%|G)$EbJBq|2*teeJBY3{$~+!82_hU$S%Mc0drP4z<-EV$Yfo&Ujfrx z23`T%J8SQmt;%*f0``am*L7l;=sg~2d&yzyDG*T`)Tnnnw164{w}vMKx^JMoK;AkBfEr3ll>y)=e55_=lroI3z!I$nH9AtH=aVK=*D6Y(N0&1HezP zXw#-d2SNYSM<)z8Ifo`?z(&y9lEM9RssUh>wf7YTdTzgbU+vVKskr8Jf3N4ikRXlr zRNw1)w06vcXS?>rG&bmR+MQx_^&I*W>7PTUVQ)VnJWEY(q(`Uiai&gXUxx}4=r*gx}JaR=|8z-7MJ za9>XuBC73mNx#^*y*hoS)m_19h52pnxwLJ`hDPrJ9Hg4ODVa;NwcF~-eHr!jm_1U2 z?}f1B?vR4oS1ZVSB7YO2aXm?*F}u%fN8A4xO(TxH?s-by{A4U`=~1n2mZzxP!a#i` zD{opXP2+Tflm*jd-CoudwWe0Ngk>=bjnuMTBkY<#uO2OJj8}K2!nP(df_8k;wPn89 zvcC%n>Cwp*nXA0R_bHs_Cw75q#l`B`{H>(yPk&}L2VUuRyAL)-ok#Q|c<@>hTsOiy z+o%weT@^OC%z7*w6bV^DP@&EUT#(&o(z$smy=sNKGf&#?&y@=a0YhioEn04z>>S_f zr=EJS=so%h?qTxKD1O_`TEK3pxKUcm>}u*}jp4-9$n?!@ z%?Epg9D}Ane{D&&4PDclnn{FhwA3hGea14Tgt7DqE;Gn`&E~-V&4oM)`u+>$`xu%D zl`mf^WFT*ql5bE)%JVo9g}&wmRxp*AaocYyG4{nRWA`KFer@xO$FVM_?@nLL2Q7?* zZ<2bP|CVtnYzUbTVRYqJmb5vxuN`c1YUSULb`pJgvw$=)g-^z+{gLpv^rAnl;smYQ zu=Yh~gioqZN|RH=Je|}v#huW_zTh~x6+hvvY#MtcOByDD&LRKeL2ERLLIIzv3)ATJdpTTE%==-wBwUE^KnIZ#K_@U@KF4v9p~0w0_)cVlR?LyySFGB7 z>dYgdcXNMnS8F*9f>b|e!DDT#&wQVb|GKn)8-wMq5Yd1;nz*Rj*UQD5NF*iw$K~v&HgZPQ^}dNo zr+LX4K1mu9bh*yR2-$orNlmt7z?J3rU>U=2yix4G+~8zR0}GSQPnQS|cLi>(sgBpt zw_=|5%#?0@s_)eH-#~eCnN2+H$swsLPECuSvMcJ@t&&R_aT7G|d&rShdPGVMn>JW# zJWkHN<DJ)Kf&1scRcK*94Iy&{Xhimq*z<`(gmRj@^`%~Gg0*{YeG>7-dwlleO$}wN% z&PoSd8>-?w_jw79`I&Qa>LYSuYnac{UgmEO1l?FYT1tM#o2b-H&W4tcMB zhYPA)@9Y`Qm{cud_+>&n*=GImy7b}c&hM@LEsWNtKNvn=8-Bj#TKzGUzT#Bvjv3k* zkz*jAFjHfko$O%#YcAus!j@<);F{&!%*eFN$OEqIWrCi@jF$k5RQ=%z@|CUz=~x~D zr21t8nW9`P@u%;L5-+-w(636L8Fa`t?JQ7U8Iw&bpI0vP^q-*6*OiKsTGcXuL~~AV zu42xva=1RXC9_dl|9+t7u_5JRyOL(Xjyhvw^FG)jrtyjV;qt*051Gc7ue>K%`Hc(r z+_h_-rSkvESU+RBcLUbjmjfCID@7YvzwqqeS0r7nK8|}|>c5O3%E-|_x~VS0xx*N5 zlr-5=k^#nZcK7lHlBJBya?)p6J$z5)hT#K!hG(4)%(N$=vcj&)%6CzfN|;CpWygOHg0##9@WmqrBd$fxg=FBN4C7O~GMJwlk@QIn}vV z?mV`*i`y0*H(ZUq@~f=7?p)EEQTs;8UDdZ~>d=y|Nm`wPCE$6!=*3_kBG!S_>| z+>phuyo`?-8%lg(i9nkSr*gkF*18`K==mrs?C zzx0}*W~f|{9QeO_`|hYF_HW-oMGv5G&QU}~1Qnzg$w8zeiWoppdXb`11f)ps%>!6L z0t6#nkq!|Mq$8rz0!Xi+NC`Fc-rk<$@7}l8Z@stHTkqX>|1m=nW+pS=z4xaXO6YC- z@@~VqAEQ#E)_NjibOM4HY0B=X?LN9z9fpZ88VwWN0r`*dOtoCkPkr5b|#2fh7#`UrA=KCiRds8$0JWaSu zJx5LEx+(=MzV9=3<()R6POs}(cP5MudT`Jnwd`Oswt6@D-jBL#g!At_yEEC)-qJV|<~(vuzCu@&=7x8_Rt@`g=f%w@r;M|mA3v{Pihy^nY-e4;wa;6> zzvObAnH58R@hswL@uRl{(r-u^a$I}A<7zp>qK=2Tde5^#L7^i|$hyVEo=C*lgT1=t zADI*(|5WOjv8RgJwa*H13ncQ13-(jZxO z&xI6-e&x4uX1iokpPNUbI^co!-e+Pc075s%0xHT}0SSF9y!TRwX_?no9KS^P2kb0{ z&7Q9Gz4{W`@olImHo4xUkLFgk-%fSdr`6fWN#buAPSbQ`WxmdrL;Sy$u3|5=h07%K zNes7pYmFB$?eeq=Xg9@OOzcZYJ<1y=O)gIZXelz= zTZ8Q>Y_Dib$d|FT6lDqKuJo;@G@41qesRWH`xa=xntA49oiW^@9AwYUi48DFXw@wA zByu~uXUcKM>_ha($D?SlhIlv!E^On?HpPjS=|9kuhia}?Qg=?@%Yc z>5KDYrquE_c6EXhOLK^rBwmlC3ti#o;Y#a`M|OzDuE6{aeVqZz*u<}N8l1czOVt_+ zjM*wh-`| zEi$a#3p85PRgJc4wVIn9Z7${6$mJPbZqHhd%#JiKb~&@~NOK`KQfRyEu7}V*M8tl} zuhT%Q6p)%%eJ8A1+BE>jF6n#LO@&bUWmX<-&GD|x^|vnbWiHhm*vb&S@@yq+GS&QD z0;3A<&6~y4-9|?D(`u`nMWtN!CPqdU{SN0YirQ2n$hT=P{Lba=qkaPw)p85`n@`gx zxKp|@LN=Zjxm7l5rfgA< zYqmL-)i7UvFAc^vEUkX_n&IqSOU>n`@(QJOi*XEj>r_;Hp3Ber55eq~zRlur*m7dH zm;z$TZ1_Wx!>XaxM$86i1dr1f605TLFAOxpYZzd3MeL#7Ef4+T7minbSho60!h-;z zo2mCG8|qZOsCl@rG%%lvsZgOgj|ku~jdorEGa>ik6N^3#H)GWV^$FT^| zAoyUI5$k%&QNfU?BrA9mPk4MAiB32|w%*>Inf5qcDaw~1j&W|-uE!Db9Cg$`Jv8+B z#L$IZm?vu=sinPYkFar8`H9{ra3|XTe{NIfji#w z@c+0-bR2;(l)c27?Rt&nqZoVgtA{>rM*f#XomEk-*nNJqQ=F52Jbg}tH#T&SDv^q|F;2gV|5L1{{25<%F4q?e{ zZ9KdCOeL!(G*3n1QSkT`U|}iB)u|E(Dtvs~etxgHIV%ydLGI5d&S&XNrmI?<_iAKH z)%mJ=Q&{VPtk-YfN;r@Yo`TkAzd#jsxCv#!@PlSUyJl`dnA+A38#vvYTpQ)9Xn!3> zEnHI=8ZbJd-l*2`y-MfN0my+;j&DnMfd$tYJrMyyoGL$j7oL|gn*j_3Eh~D?%a>rn zNp5c6d6Isb#xyCG*ZHxWkkD3(n|RV!iq%WCd&RmRd?xf#`?#IHXaOd*;?KrE z&h}iNpes0weE2Pz^4jy$D>yqC>*@U?EyLNql*OcAv@uH14tG0ZLNw-7_1w(lPlKb;CERI)59Xx~?dEzt3 zPvt!g&-=G}oZbJs)0g&`#u(@A2TDz=0CF~)g1E4Pf*YCf`{6Bm`oc56X8|THp&f1P z+i!QJn6u#Y!;YRcp)3a`6n~MHzZ`@M0)sa^z*l^Jp178|6ZcArl?B~@$5;@fJZvCk zdd9V<{y}rX7yc`O*?$EjR5eB%w;zO{tZZE~KS#kUQ0iFu8U3dfDC%^tYUJO4Prc{V z$x~V|Z~@fk-tj?Sn8$6d-jOQTHtwh#n5baian=K^gA<6^EdU$Ni_U9{tLW$bta88U z@Qt16mnc`?Iq9=}P4JK*c?X7Gbopa=9Wg$$B%~m>H4qfLwosZIy9VojmX3N0Y6?|l zHjju+Npt{@Ew;^@Rt5Z0+^1CYCv|m3GGd+ly3}X<2XUROSGClrRhjf%CU}WZz-;po zB%TdlT6E{fA6&^d^{AJo+VID*>Y?NVh%QhRPF^trS~s@+x6V6#iR2WkxG0mwFM)S9 zVPaG}gsEm{DkUEE!xj-)#nt@^DO%BtWd)yxHm1beM-RyK^cpas!VAu<>}Om})IY1_ zg~uW2+K%?CTy;F+oY?3?o5zY<=j`medy`>d+kJp5*t^tbi%U*PIMDC<^y`x?NmtBN zMd(0mlRxL4C|mMhV&<}}m8=aX_gz}@hAZi*9w*>_L~T#T{7e+a36F}es4 zVrv_6LVmV~*9DFVDRH*Lf0szQocC0+94t)?35zf4(~RI%Br0iv3Zr8#%2c;7IdpET zK*qfEWW>x}OmHOA-0{QmptDF! z`#;R^Yh)|oG`HJ+uWI^}6v~B7x*tcKcC4=Dz2g?FBPM>+^2+s^KPAN2)sCO{#NZ+x zS#|f07S=K9ym_+^lmjSv0e-s zuljUmDsIYxjGlI)^LPQ{lLe!zy9SkkrEAUnk4>3*4mRiP$A)r|--LapCVFejqB}wb zI`&R=zj6Iev%;T?ffO~C!Q-%l&x>y`$fHggk28a7DIwD4Oy3-(R6VXv?@XPKqM7k}vg&H}AKh%Ep6K%35YD)nsYfPCIm?-Vw~*l?LeG3k$hD z3Rp2A8WJ|oZfsPS#BPQ?gpNNwl`x=ds{6xs`WNDd3E`NsAN?xq4~zfVr_q!9EMASX zkcSP#ROr8f$NpQp+*o}m^@ZZeOr;8L$^*7CnySor% zKwnwU@V4&mjm&S{K9}BZR$so+JieD?f^)v)eFasn`pKD{S7-UeU&OjDlwO2o&4rEL z-P(C8WsoKeKnd;GGp?%QZ{42B9!E?kK`Nt8D_1Y|uXf3*WvqEB;6|5DW&9<5D(*)5 zoU6vu=C+dMgvN%>u7|sPM9#@{bAAxH$^<^m=eT}nF6$%8J(wi}ye^^JAJNWx|F*gD!Q%F%QYIEOwS zN$wXpHdSwSJ~Tx1BopvF8)Y~FqdX_E9Zn7`u!+T0Kqp{D$b))?a&nj{X%sD8<5I92 zP|jWx<~lmt2ih4-GBdT&-7BhwcrOwq&JE&~+y$@dc8^e&iO+!=asZDr5o z8r%LyZa4J2y*&rv_4d`$A{=H{r`<;mlf_4 z&<5W2c-D_xHLwDpRzNryW-OT_q$<*a-S}}mM&H*~ef^PMft0_L-9hr-t(vZCeUU2yhN3FjPii94i+Qf^ zgj3QJK)_=xfivCwi{)^(($U-?>7_N)f%52T_znC`5A_(CZ4LC zbHSYMsZ#NlUP??`&<X@ynkMb7X2ROR7` zEB3ddf|Uy6CRMi7DyeadQ%%DAPwfl}+I{$ahey0&X34^rVzGuE4D4<1ul z%mHjI*RUlvNloqe*Sd!{^sju+NyLrR#SVR~!<(`1^~zUz4Q%#K-ctloBCgrm_#CaZ zHn5p`D<(wv&*})e3yk(F({RpyK14+p-+`fn``P?X7~_|yd%1GNP|hNWh?ca!q&*Zz z1e;eIPupqh#?hS%W>-IS!lK@B|8Z`WKbw2!+W)>>MTaE1H&pzdv=WxZZPI+QON#ZE z(jXy6?8tJlQAUrc&+wIwF|c%rVlFIx*v)J8RpxgPwGCqvuht(fg%+HQ$Kx&MFgMyj(LuD+QfBhVW=g z%c3ck7ICl8PaQU*@3HZ{fx^{Uxpsr)<8ES8Fd< zjG)K_v&%)zz3dz94!9hbgy9bltSS(Z<9twte}1c#ZlPQ7GD0or$~;AvNb6 zBjf=6c$iTFRpnaa$y1qR|E@H2Dp)rOC4AwS}tJ(&q7H_r2<+p^>4!+ZFvYhEFs{q$mR(#= zuTOCKvBxfbYoC9R9{$uT;F3b|&Bp?zFn6O^gAJdMy)yVy6TQ+UG)E*AhcOb>Uo6y; zrhTblU6F58i+h-?B#(-g3(IW{AMSV~VJ?i6kv8qPnJeN1itI=Ahh2B_73K&l$9rPa zZKpF;u+UoJ4hAzg4S`?%WF<${-3R|y$HUSn>}y@1 zd%d)W=7_~1&L;Yq9vJBT%tK6N1zj?8@KzUa%5ljh>|z4D`)Ev=CA>ZuYg{;*H>;%u zbI(?axnnFzRcG`|vlDyw@=U|_nVI$KCooc)KhA|tf#bHIZC@3$H#Y9|%^}3Mv9QS0 zwX)V1`TRxD3c(c$xLNn-4>QnqZO?0O0k%<18Cn;$xWdS8-36T+S_bDYVI%l14>*S{ zO6nHq={`Jm>fYGHCE{B1<_5TlWlJg9A5lF7P;`4R;y$^aSys}?$CO+=f0bv}3tMZ}iQ1C|qWTs@g=-4GMlhgFIWx z7Ovc-RO*%-MWJ=YNxaWl1$4{d|A#sBZ=m}B%$8c3Ep`nJ213McO`gbJ&=qLi+{p8M!Nq*hPs$PEzUnu3LI;828#W28ZhPq&1rVq1G2uh`9> z#g3Vu339av4DC)x$hk2HHd@=;=Rab6aoFtC3z{BV0FhBw3it>L3s+dJ+nqA_iR7^W zSyUJqQIc9*1)<~zh~|)6iJb-YA#9^;$qsx`P#+e1S7N7H;Whe%Y3=58p-(#`Cz&v( zSOA&N247kMmPB_7SNVauLrqpqsq~LjkXq2^pvXZA3u(oRO?x~m76Js@EVEPws?6FQ zr?^AN_`BBmT9dKHKI)RMC9UUvL~j-agAmIAb1=C(I}PZq7^E7^^-J7kvtXYR;-9O? zTU^+MD!qY6FcdH~r6=opWY_Pe*g9!ayMHQBZiaFLinuMZ9-a)O7Xl#-B8MT~Vt!(b zp=7*P9=-;vK-UVD5GrAvptlsx-a~vFfN~o65TKPnbAzZ12rk5OIkL`t_c!BcFkx@$ zTJZEQn2a8DuDMEBv#e$TMTDW`I#TQUZ&4WG88<#;Xzb&^Qb%<$E6WYBA{`5^F`SdY zV^QLV>MQEIs|6sh1CVn*H$7g|F9Iu)570pI?J(m6M#+2P?<6Q}yYAqfbW5=Ln0))N?+Qo!#2xx2|`lHZfr~*%G#Ey`Z21Bse*4@U9 z$}R*Myu-jK4|8$8*w`gV?FJ6a+I}A&$`@QRsP%6?Q7m|xw#NhEasq2~3jR$(|99^E zv*xrX;p2sJI|Ux)HpKaC+w9+=dQ97-GMw=Ne>|Rw=TxL`&p7SD9ogoa1Z`{lCbn$v z$6YiBGig`4-se!Ygl7$I+FsQdc*Npp1Y9&YS+(=NvjB(@7)n{Y-%aF0|0eZ7usR+;nq;;K*0{HU$rllrOpGTVv_ zQiR+zds|i8L@L3i-!Dy!=#wjgMRY<{)UzZXY}2?`@fsABg1 zymp9$p^_>Tw~cIUl75q{m86OW&v_2>;D?Rh*Qw8AXkO2`m^0}c9tmLakB$M4Wrs3? zOJe>E4yvbH9Om~I@4qaiS*CicgQFvyOn?{aA}-PVtj61w~WlL#FBi0=u_<#91s zebiTH|JR_XbYkXm} zd`yGz+vukZyQF+~d6sGkOmO^T!&W~#5zw<^K6|V41v5_*-_`1q8DdWL105;5E2JN} z3T=!md9S9SJ@LBo^MSs!Mhz>;H*a)%c@*LK9CSiOl!-u+@3k4`6Gn)1vP-c;VHLNT~!oH|fccHXc98~6uU~1PAdfnb% z-kFi2-9T-6>`uDoQIcH6=2`QdkWT4K8B zb9m$irzL&=!Yo6WX2L=p=A^D!q6vT|RKEhHDsP&0ei0?cKnJ%6ly_yaLj&BaTm#rQ zYhNG;r{IY4Lsi@&vL+{ zO~(KVWM#f3LR*s+jV(+uCWPNT0nrTIJ^6$w6Z`hZEzR_W!mA~DEE2INM|w`Ue+T@t}}!AFV(LkQh*WivrYPU#JDL>}mGC6xB@2 ze&B6?)6vxY>_c9^KH?ack_gm-l?RPxbXoQpFirf*obq_p<2hlMFl5o;1^W_Einc6x zZG%e7Ix+Um1#Li>cBX?KhfrvlL8ayYAeH7={fgN8Y5oiz6^@}$OXk4R*{GmIZzyW~ zhoJ*Z50u|2W%xT0U!0 z3Y7@*HI!8G+$?Wt+At(Z_<*Uz4qe3sZW%zN%5AU)qMEBpL?y?m>%Y{q=vI7Gn^0+@ ztgSxk7NMwP@M>#1 zmFPK|Y61dRhun`;$bTF^9b9OuQZDteVdKS;41~#=GWU80pD#RfYw_XN0VD>RmXP~4 z(9jY$KdrIg+s=M)d*&VaHzYY=s5=IRfgxip(amSC{YX3s>~+Ws!mO>=9Dnp_Ev}*e zfC*^Sx4M=aOQB;hR8}tcCz#hJ*Ux?uuw6d!dKU7hdtc|i!x0HJ@X_17rp&6Ek59qqL5zK!&;(^mpyqt1 zeVYC}-By0E3Y!OaGHZ(z8uNv$i-y+kKl}X?eC7fH%1qe74j}#s`PEUIf!I2QPE1JzGev8ke_y|HCWw|Fpcm+hWQkwdEn>sK4 zWlpVQ@`asNP#0CW5-b7|9maB9Yb*Cw)9$_azjgimJSW;nNmubv#%4^EnphDhQSP4V z6o@FG_Tm~CcoZL5skXds8!Nficuc!ht`Glp*9)2Hyd2>7N!+c^Yn50L4LTkc+hYQU zfOhoY)WFo9o?fH8Ysy*7vV}{(ztBhaah5jmp`bi*@ROX!^!qAf7$j>H1lwR5%jxnx$w>pJ3gpW0YLe8NF8c&nd+ufDf+DdDb- zj^~OmYE|)M>#FzreRT4yQTYU-I?q)*?-%AWfdhw<9W5MB=kC{LI!-o1S zZdjK(Y};s%c^)hVh>A%q-&vyzV9{3PUqQO)h9= zKTw5>j;i+~_n(U3+feu$nv{)$$hgH(=xJhatGMLU;dnp+N+_>5ndATHL~%LT)%~jk z*$Ym+>s?#$M2(Lh2p-pEPdy5;Z!9|lI;8k6#kh|6{+JH~)uzRyCW30fkzS`Y@Uiq| z3=4h(4)(bree~q-*b=wBB#;9gLw*A^1jDY5P~wsc>b<6^t_bDyx1vAF-$Bp|=BJ|^ z_*!Woa{Xh5d9t-pN;04pBisD`r!Dr}z{>k#m&UUSv?L)ADwpa9nL{8DkbyTH;4cB& zcxY_(KY4m6@I1S?YREjwQGN!%Jw=SA`EfK7aOtD-b*@E=Dh~80cDm4Zvcvfz^Y{2Y zjy#dLYcX{o=LXPQF*`f0MPckGwJSI{g6bqayr=AMW!VEZ&H@)H89k83DBqJ?`)B_7 zN8hRoG41H;oP-55RV#J&)9YT%@o>L#6G-=fhW%;?b+p~F*%{}YVFMBV#LTO32sY*$ zul>IUQ9m>A$e2%$vMcG7ux4s__qP{>Mevi4Ao&~s0)}WK;`0i!r82r-7q=SeM?Ey0 zDv!T4NdBtqMk`M<7;i)QPxB4tn3JY_T&fpvx`tK>#9$X>QG#5%WIo9qYO3MFimM&4u#JFd zXs=SrpGU388)GxG&x-N)zSi7SlokC)XaI|i-flJB$7^_+H)ZEuMfK>Q;0jr--txrK zrak@V#TX1y*ZHBK&d1g#N&~WlzXCtbbGuhJGFaC9sE|DYfY=VQQU)=b-xI#%#pS7w zq>rZl4e&lT?JR?9+@%SLRfV?j@vYV$DYwYQG?({8rr2NRLazoV`doR}(Ni1S=|ZR9 zY@Gk2wX8`^&UQ9{{Bl48tD3?e|x*~ zJy?bi=9TpNaHT@mksV2aoRch;Z!;WZ$DMxJ<6eT_mebcfFZ2!lo2BTn7yqr$gyeRj o*C@67b$UdX(r@zq+T(kNa?X5u8mAcW09|2NMYU@=3Z{Yo1MnXG9smFU diff --git a/screenshots/widget_vtxadmin_fullscreen.png b/screenshots/widget_vtxadmin_fullscreen.png index 923e4a62a436fb5ae92ca06928ed302503cf2e9e..6b31ccb1e398769fd19ec124553fa537020857fc 100644 GIT binary patch literal 21644 zcmch93sh8RmTn=MiYBo-W78y2sk!~KySbAxMkOjx_9IQtbrL$$i6lWOowPBLj-?WW zlJXpsq*>8c=1#5yf>KVL#N0+KJH{>vj{`Eu1Sl%TixuT@c!VkusRu<-uk*Nj?<&yl znRL&(GwZI^>4jCN&iT*(?|<)afBW12J3oImGee)6{pMcg3#_qF@) zgHK-j>6zas6wm+SuYdBx=L_Q|n&VG>`=x)p;*|?rNB*_z%$^OeoO`49_p$%I{+sH5 z{2%7(6;FQC720drvG0S@b-6v}B-xd1mA}o(_FcBLP5C`9WNpsxk+;s3uk}rBIrqu! z-`2qY$xj`WUq7lYtzCoP!X2FwH-2t}F8^(fr21CS^jdo9 zHT&-99bb?$o(q|7e=t7SmnP&IeM)YHUhF7yghF@J(ko4&a#OSXDlfE{yEbTrwK<^? zwleX{dTsckVWgZ{u9~u223oUZVNq~Y%NA4;d_894iJp&# z=hvr}>Y2-jG8yk0w|Hyh&bf_!`d2*2=cq@f{oe7tjnwPP%I*(IJ6WNB#cJ4`&IIXcNS)V#D zx_0biKd`kobjQ8;O!7dmyuh$iC)n#1f_Ke}&+M|*>y*v;d6#w`?sUD>0)u_rHW570 z$waDQ+9Jt0AC4zIm|1sB{+?MJwtHLE@|A{wU0%%zPRkrmi(YswJ#tca+%8R-9PL#Brsk4Nn_(i6U9RB8-ps+nxU&4BCh25oOxtvty#Q>x2>3y2$m#C_i3i+NIJ9pQ{{9yTx%Q$&;EMX_qTTe6bxMp800s ze{B`^K*2HHCVdu-G<( zJHuvZa^yX=|AhQKTBvKRFt;h(sC%?Hw1lR_E%}#u-deY}j?dsi8+^T)mrCu@9h0Mm zmCPYrx%A)>wKcvmFs`lSrASes+bqAaGtJVum=j7uE*1L*EUkX@Bqx-I#?SP%s#sxh zUjNvdK*XV9whe@jsk<`RfZErslR7sQ7`rps$;D5a@@}NCp6GO?hwJ5v0%=m`_+%uH z3BSu%gif%Qa>lbe>DC!JIKKg(oWg{ZzV%w6*D`74gt;+ikooke&NEoMMp3`0`4C$> z{(DD*zoabQ)55W;z`C5Fmh3zQ?#OwcxLS6(%!cREf>XO)Ay>e0Knh3-5;$Sqg z8)+|kud&8tvSPHk*p6EJ1I@qec-eb}Yv^Qu%{OgtQ_9jI_UhQk&kwwl_-Ui^~EpO@zOIxd|3Zgp$J<%Y>Q*5s~aP4fxE=G|P+zxcA8yZTFpj%LHc z!{*JYK+hAp9r!i)T67GPn!4=;^S}i}m!L0cfB$YpQ>0Z_<@`OkUxvoFj1xEbZs_F> zqrZq-ZK>`B_a8KeAUq@#2c+A)<6T~QKXk{!ZbK`m%C=2Rf|s@nlP11{yD~GwikwKB zdE^2gI!2DgNo)QoFSi>f1M(AY;ik$_!$&THOF2YtBldbLpRX?K`FPHj9A{s++rFDq zGaZlX#6Yqu(5W{rRefB4&@lRuQg5yEpEQp!fhA`ZUu@oeBU@Y8ovjV7bp-~v{VL9= zwDHf_Tb5cT)uSbR@Tvm6P`6p!!UQe)(YFnr{Pws@;LB{bwEAS+4r~<%&(U-x%0ZnE zEqYVIzU~apyTRA5mTopopOIH)UV0_5m_|x2(=~OXj2S6+peY(AGUeB+ceOt*KSozA z>-$}25htAveQ9A|ABfbji;FY(H%|nZ2?$_Xsr~H6)J~N=UEAQ@z;|SGlO-XvfSDcs zOB?c(rDM{Um3&$2lo|4!1`7i$ZfaQF&U z`u-DWF?Bo{{$`j2bY8wsd9j_gc$#x#bosm^X>t8Y^yeqqj_66@b<7w7JC&dp)ry z1ixQzoKl-F@oLV$JE>un^T0s3+&xrg^JsprvJf^YvI=Pzy-aY=TMtb*af-*do zorO43HPx-scAqZGyRi=7a*4%}`dKFb3BYIXdv&gs*%TpeuC0B?LD*V}uNGYVyV(EQ zE*Skh_<;0q8^rrhHR;Z}{a&4W@|)|JCBJvf{&o+&jWo|Be4dAw=5erJ3MWkb=)BOF zUE7_kOM%x3Ur*4BgKJb%t(iAQPs@dET*p$&XPZTL$7&FI9X zXIz(#&Ez$@ql4N{7wsK7qwgOLg>G}Y-&F?fz5Q>OB+O(&+>#ddJvTsdr?cMl(J=pI zb#`6UJN#CT|4>-A53wFI5E%mWH$Pc=;e&*}JQWxN!qNe={Mxx9w;R%+5e=KWSG7^_ z{{mt^cpObvE*s9gQ2(;mu9INJAe%tM;y92Vn$T62F}`xgMXh+oaCvEc!*ZSAGIyaf zH+Yn6N=<_N-Ym3D4Y#rtY?)iSWp{v!Ne@Njio~7*b}Bc|R+nHBM7ArIjQsEWrFT=q zPa#zE3C5|zw&Ph+es_QTBF$$S*klPX+2JIKgY6|G-P6@8AZwZxG>>kQE+T*4L`zcdO*bSmCXr z;P^V%(9?blj|dcBO?UDWCv}mQK3~7vYV4+0kd@C%?;0mJsGalHfakgj)(6JXcK8nL zudTS5w!-GZXhI+$zR};QeWcheO)NYAa!Z=iHFN;L(J*?t>R#Hah?kneqV9N#bVqmJ z;9*`ohIkHxqp$%}jt84@Vj2Ru+Dd{jW^&xpMM9;qS^W-}K;h^$&y^IbVQYHuYF<-> zY*4fAi?<>EeTR@Cq1QpZ^a=nhgj4?7!1dGzI?)e_?U{phX+z`uaeP_N;I%vq9S=1Z z$l*m42Fau_@KZ=gR*R54?P%M^2{-gzwSha67=IM0GdI`;VGGwtL2xmeJL;r6DURz! zdqemVu0Wex>Oe$XF_N9fgv|O;L?dXbbQAbcu~T#e9GFHJJt^`NnZa-Y{%d1Sc&Obl zrqs2g@e{xvE?}eC3cSf5qP%5Bab2D7!}%LmcDrdlXyTgTDauF?Dh8Xp09goB%j!XQuKIa&-6rQ~k-L^2X*`1p@z?1luI>0>O5{NFL3{?u4&YbKzpAyPNpwdv zqT{x$7Y|Zehe`Qm+#K;Drt`SSp-~(23T^E)UL1fd3cN!11A8D)0d*sI+~MZPGqH9AJ}WF1;&zY zfOH3vD1a!(gZMUVnVFHZVxTQF9ALId-$CDit_34x9SP__rg z$6d!c`%B7Y3*_lVLCkx|E0%FWEwG;Ka`|P7>*OSQwDR1X$OX2tTnei3c@Ow*jUSEQ zWpf>Z8GSKsm+>$`7TJ2g6u*CRgUxUX*g_0{@c_VJB>`%+8=5vIKV|9Uu0V!_F~)OU z!!Ff}ldu!e%B9+Y&_y*6ums%YicHC+C87g)x}1t=fIx1Of-cT=U)bt?2sYYeNgQNK zic7BG3Hj3|G}jC^s3NvgnAY*FM3SQGGJ6s75S51b7lRe%Vo>gNms&y;j4czsDR zd=y)u-*g-vEOWo)?E$u~KfyNAlI;O!P@E8mjOXpL9daV93l4Jw+j0yG+nqout?A#L zEPsPs6OI)8d;z!|aMBxnYmC#q+zRr;oh*~AfQvE*W2rxP44zrYkYS?*tHlYnE4~q< zL=X6Df<8E4jQ<^FO_Uo3O%AYYV#a8u5IG>sd6$#Rq7Y8>jG$Y}`MPh7Z6vw}3|lc5 zfJB5sd6b#OZH&~CD+>16Cw`zg$Tukw;A^^Sm7X@XarkL(56cZdy~ma&WD|TRABrp# zrM=nvfdyFs=1z%kAactsy@D@ejlxV|dQvTz-78J$-9@rTkD=9Y3Ny*=oOsB>gvN83pH-&_eb0QnW7X&{}@LI#-{{$Rv7RmH8;i9Xi4BTARcug;Z!ak5^y zFyACuacg&5w zaDETF>frW$BX(w6k}>?0UbtxnN?=SaZ^eHi?z1o>QIZyevIhViFw8UG;U>V)^sY91 zI&|AC{oTwLoHP#nZGCHU?(@L!k-rOj|fxlkNO(*;MdC@9tH_#v;9$fNY zzdL!qoNJr9q8G0NYId8NU9QJfQ=Od9spzseHWgeeF+69S%3xR6``=uu&^Ax=Yzjha zV1SJQmAo9B>zTd|#A4tDbV85rA*c|Ax5sNUK{ z)M1E{I%!nrU|7-4T5oTe*SweuErzghT`za1hT9-(No1w%zBZG111Hlsxt(2Yv%v%h zibk{8ZLUBIOhSV&^T(i@Zq?^2|N3W1F|#t zFDG zzSj6gUpDsyCtTB#g}~xg;%^Gw1}W za64O}ktWTKGC4e-ywlwgZyE^J@{hsOCUg$8)3^$~)qu7l{S{fLR+``)46Qv_`{Ci! z+VH!f5ssOo6~{U2X`OIYVe~|}6h*zVzRz9C$@L-JaZE76RYfMiVVSnU2>VMmD~_tQ zfZT=PZgLW#>nC*5n8|Tk3%S==sTL6Ft+JUCll}f`NcQj@aVJEV8~^Ro`#)97R}~GD zx!h{>PiO^F;)VM9PM?oF`Q!#HT*7u`G~(w_3}*wX`15OD^xDDcEaBI7i@4@7_ZRbv z4O1JqmGbeU`lZS(Fw{gxEiaukTm}f;uN7L2o>F#Y)McRSj+M>j?76m4oMCeFnZd|1 zh3AT1x`F_%fPFI1w_Bw!31cQGwWAx?NOx?G5E&-g5jlO_m6$?I1G|lT`KDRE(&xsw zRmUgduE+Q3a|gX6iaSXFk>Q&7^F`-7xt#FbWK+P0KFL=CfenU~s>Kj!MNi_x zu>o=U!Fcdiru<7L*v>-C#t>u@M|EA`o|Mgy+AZe%*}hJWcof)dhEY572D((J=rn38 zvX}`wZAAVyc^zi~tH*EC!`HOOBTKdWm!f|Njp^h!$On_39pRxGEDo1}pAJ>g){7P5 z*ax8SYv1J8e+2f8G2MreQb87mj`6`}opy91ZmSp{$%-Bon*toaTNp@PbgV|QQoN!q zjQi}k-ulwgvx-6-*4>|FH7e$2iBWlNh>&IQTt8uNI@-ChIV~ASz&?uaveGsJ ztMb0&bA|9z`Ek`iW&HVJz|i|1{@To>{4u<>wJSNf2${uuF)P8_Oc@et-6-yT0|J*A z7?)JlHBVIl>NW3q6SuGfpx zZrmJxwiiojJ|yH+Q^YmurPnA+!f)L_@|ZscI|G6tgI$SW3B3tHD;nN<{D?e-Nw})E zBEF$d@M_z{Ve~aPbj(^2GN8-Cq$W^l8$zj9$W_L?(G)7t9ariKPLP}clQLSn&G+Zb zMbt^i4Lm1+V>N7FM8YO%Ozk+#2{nW+X-AhygQ_SPYYL5Xl@M8)f}LDtfN`rF6n+pA zx&r7A3CEH%Ago{r zK|Y*2@~jAFA@nP1`k`|8xkmat^)v4^`|s-OA84+%40M2(SjYL+`PR2vj-oM&y(s=$ zZ5D56tw=*fi?BKnw^-x%&kk@GkZh#GIXrBJ${QGaxZw2Uk0=ZWiV%A2Y#iCx6uh9V z!bQCg95>{0Vs9* zXO`lTLGPteHRqpk3e(%URY0pWEC-qHg5G56RZT+oyts0d%ymZF`b-TzJWWLlOVmQFx7gaK$4#J|1)UE-D7Q%1G2O{-g zF*ABXa&3>#l@}1&!?Fi}T-yX-y=?%Sbt4EgAVr{W>2c#Tj|1Wff-Oi&NHKNqbLQ1S z4US4T<=V&*5`qBg25y($?Wd#jzJSDy03ZHQ#>!Ms(!@DR zfto68v=I#$=a}#rmXxf#duGybjUW%PF}X-w+rvN%f#R-8uFy&2*`!IO_jvRZf8RNB zP+5`j_{WPzsvJTF74NHe*&b)bQN$!D%mD&Eq_^fpM%QgrG)#R1ew^TG)e8NopTX`Z z^8HPe*^n2Q1khe0Gmf<}dfitkYSmPisij~F07NV%1&o0r+D!Fd8A_t)9p;){^@F>A zA1Yv+0mr<*q|I)x$^)%yvIN=s`8itV_@u1KGf*W!3q&wCVZQZFwMZp{pJwP=djDyfzw)i@FZ{n>g%9NNSuH zIJ>3bV1m5L(d=Ddx8hXhycrAe=c-!sX$o5ZLwry)orDekpQ4+h7~)Ldg@gU`H*WIl zwk}*^-qn8sUfO<&qLt} z2HZloFjXcmpFtg((}Jp`^O z1KbE^2xj5)GRGNKC^ekIk0^LhLU|R6HTVf#t=xwY1+hx#%QES;Bw+Rm@e=}GJGJ77 zc9za*z(kT#Z~|lea3d#Ks3fcwuV}2OvG85j$Xm#7AoC0BB0meVnMIN`zhUYyx6)=) z%YLZA11IAOwDNL4K%&Vuoz&A1C}Nir2UWlZQeqt688%!R8rH}=nj)oa1yUxI86)Lk zIHd%*mqnPL@Nb zLk_dFqbXR!MJXrtNK87=otIs^5#Enm)WTgO+g#tCscR=T5ithE1!4-&L@-wcG_#|i z4GKfBc&ayrseGhc*K5RKjWr%qH-M-LoV2qGH#T?|b5E$Ikbt>M0Ld={nCR0({SL(! zg(%($jB8<7;R3+%XZL0a*%Q_ErI1ttWY&t;v|Wj8D9iV`M(zOp4KUD}L;ZOHkLE^N zfl7h=uEIE_#8wDE!|_%imua!GHwOlIt4OjARQkbAmY)U5>iEu2j&f3j!zcpOIkGyH zWzwxo$J1UkKI3#b`vh%D_Jff#=J>%2oNyQj31%C371)NSg~{#Q6Ij8VmC-|W*NNyC zCovv2{YgI37{ctI2tK0j7{a7z@<5YlD-zr8#Vy*K$Rx--YS!RNzki+8BcMPU~V*)$i6Ox-6-cqsM*BXppZE;Smog$q^C2brXmJ+f&q6*VEI5517l2bdf%F@h5{WJgG-Fl=L6%(MBgVG&xp6DHC3N?Q2rcS-<#$tY@ z^S3e(A|NTj>NAx+5ni=J4*^LZ`_)*|+yH8>X{etn3djBmUQzcWgxRpr&T*6rNBeTZ z*dvHcrqed9RuVg)S@0&PSO_zjEi(bUb;%#c?ux9!sc-y=*uSTL3H3OI)kpuU z%2bHm+7LPhzlp1GEDTiRZbj{!Zg=uT2| ziF3Ib=k6dD{QT_YTErTs%UXfT*yK04ua_i%E;(V>_<|%{tj6!rV#$6WRSqTRMAk;3 zOD+m?b9L?8k_W33;5P5Yz#$MEcdoW?&2a)e%FS`6hMy{*c$S_PT`7HALKM@K0j7z= z3Zv#PzOon?f*Jy=$M}q;kQqLZsx-Jo|1gC2l*+f(I?x7VP)>uJ z<|<_Pvp)Kp!7QqgC@}kU1!mb~95$jVswRT`4KWphtMzVh{nmAynC-jEjX;MAJoFC= zQbLrUodqse%wh*pE96Dl8k6KzgDVSPouyl%)w@~5vmTRhoWE#6;Hyzem>VK0V;H3T zRH-JAf<=Cl07_5{O61KBvTsABD*=Cw;ZdxbL;8o64h%WKSO%#814|;d0v*;cJU$E_Vnn015)`Te9gqoj4Iq>lC@3q*tJK1P8~HV? zKHy?g!I5ZU;zmA&k098wE3qnpHAqA`4>lu@ADsrQ3c)A|1w&@o%RSgkagYdSx|s>V zINi(&^*g+tf{Kc&%2f5FK#gp=g$jNY%rmiufXF!H(91i|L7xEy6pX`A#&crzsE*gj zQ<*d&SV;iMFw*~&CR0VA3aVpu$Z#WO!o4H+L$?t%*yV!SE%h_hK7h`Q@SbN*UtW6} zS|x5^jfgXCmTw{`y2eSpSdzqoDP-%AcNRDf41^l_#ZLXb~gGqZbUiF5xzpXZ^F*Jf`Ic;2$R6| zA!3e0?qWVG&m9O_`A3Rl<4IvgQXB_zA{Y4z9?M$jX!!|bCQ;EyZ*+^A9K129;O>aTSdJqFT`me3y4Uco*fCz?MXSE4~y5CJ-P#L_!L1a1{_E>7fyAr3OI> zGAb0;6+8ELU>_zR;`DHdwnAA6TtnHLbWbu{(g1vhWDnI8tU9>|(Iw%@SUvc`=Gsid z90egj`pxor17XAD!;*@ei;RoMsGa{m@uj>I>w&9YqL^06+AFO68nKX>Eu>W2^A_8 zR5GF}Nu&|WeLB=qU{L~nhaxBZzV5N&qMePU<&3+WgdJ#+^3SmB_fT^hNOU)Ffem>a ztPV2c(P|JSn$;lqEn$PDn6iEqJ{9@h04X53W2*|731w8w5A>qq7+Xpk@q$)*cMT&z z;(6QLrISUS4;PRe1BN1o{Gf`J3w$jA8vEQwc;N{0R&NZ1`awYu{&TogS_QRY=mwCt zRrjK+h^NUlPPelw6!c73TbdDcR6cY{iUwa1mqK0%&?mW<^~9cSXN6_)o({9rfqS>j zB;TNHkh~Bg2#$BP9I{BmI@qGnFbP7a)$pnMtq!ggiMM!<6$(H^rT+mT=UY$Xpm#(U z+~>ZiiyS$S3BgP}W;hk4ZmYO(4P+~-4v>?IEedjk zGw(oh0DQGR_e`mkqEuedpTX1e;(_pC_Bd!Vd2z@LIww*2BPjN+LR2B|VD~p~(;Ke= z=`zwV)2|F#XA!i{#_o7*R|Q(oa0xM$A7K zq_s z46vnG@Gf!wGzp-2%#@|th7SkAJ?gGR;Al_|&Hgi_OcwXhOG%rCg2-mdb7wi)?Senf zHVK8!hVIY=blnuOehoC3m{*%XxI4AjcM}A!^3PyOfaASDYUf#=|s-jw+8jSks9{6{4>;dcY*Jee`dEj zp&$06>}7DN`~c7esCYwaBI(xqI^lf-%AyGDQfZbXG#1d=)H#nr^Am`RFq&1~)fs`a zM0CbBDuEHsl!ml0>)QVVqE|G46i&|dsmEH{hpLOB6D}p>MEnSU+?Nsnp@8_M1k_S- zL~UZ6iYr8Y4K2z%(x6ZP$5(SsW7Iia zI;Rmk&_vEANf@zU?Cgc?tWB%UyBfMtXQ&k;mPyml8CKqidxQPwk^F9OX0^#UUGewi zBfdT+WRW2ym;zbJ8CJ9qtC>?#z_<{Kp#ECJ{TQcl z4by{-{G%jxb42AbsN6|K-3}P)IjZ0gcER`nF4tN< z0;Ls*Pr9-46)1v^GmxXsP^Lae1yyfC4L(p0q&8%ApowFkDUQ}|0z`pmHuWRNI-zHF zv-G6sUkdyk1vokL-v#K>w*b$~@!<0)^#sBX0S+q0NO}c_!F)ecF&G>hFFb|0E{dlR z5hD%!z@C2##4*h2Q_Oh^6#8qgVF5d%%Dx759g^u(tUxUvCq^Mt3YIyLV~3jKj9?Bl z0|0{07rBz)Y=?rxV$5LxdO|U`uBh;bP@!H}Y?CH6SS~q+(Fxx7xAJ$Nm#P}01%ZAZ z(@8ADcli*0e~ww?q)lEyJi3U!XmK%Czi~bAJ5&tNchSogeR<<)4O2zj%0MrqMQ{;B zVtFt4cG!hb3OWahA_dt)xlDx1qU#II)TzJtmTBtXQAXn(B=gW^B`rV z#B8ExLp5%UhYg#7niWnX6b8XC(4km1%Ik-s%Ivz57!2fok^RU)<4qonk_zBxN$^Vv zJhJ+@;cmF*+FvLf1Id3U>=o*Zi~J$FtRc!D#?>! z#x|$~Hu}rxexm{b?jqa^v;z1!oV^0X?S-40sY+&iB9@VNyCvv1%mktIp8=U{eSgBH zl+rBh{F;O|81^6O&y3#9=on11QJlS)oppX)OT*<{=z9kBbi-u3yi$Hi3U)?y-t0f) z5g|JcjwB?dBI9eOs-9#o@|@`0xaiKa$>@po;%${f3k^}>64DxH+&sDQ>~|lBYcSdu z6E&wDy}tSJ$S|}iIP#<)Tb*zwfY2di)aRGuHNTEtGt_bn&+nNKOfj&il(wdv5-P$}O}XAt1OrEk@K z0AII5fjEji=KSZd$1JL{eSxSdhF!2X=TkU{Ntl9;ufw-%bz+OryN;4dA>-v|5a?Eq zwj><2;m9**NprTtwP5E-Krw&|;Gm`WU69l+=A66?m;^~UwMA`h6A*0t==_D%m84dC(nM6P`?OnQ=I?@scc(F`n0fox43== z4wbn6j{NZK(-+P@UElY~!TxvTzim%WEu_l+ucAfuEV3Xd7yti+=952)QR*?)_65zM zHM<%v!+9Bxz{yfO4ubWgFfK}W+h2?e{LT3yg$j<2nG}9*NWWLEH+vPFcXLl!y^sMh z@E_FEA!)^jHNJ)-zl-LPj}ow5taPyM&a<1QZ;in8MjwX|>ZIYM@74%}Yp?LN)bfNZ zq`hOmM-dp+)vGoCPx(j{HIhPG6`kdD+%`n%^K`5^Sze&Vjvs7D#!fnrhd|%f8>H2# zhJ$yiP1x+`2pYSQb3>8RFTY0WA8eVb;~@*s`!ER^2tghp;(1$?)Rj`h zk65$cj~&R^4NWKH@I_qg_yH5NT|x01jz#0gkV)-wdayvO$722g)KJYE#Ev%<`XG`x zrG)NF92Uj8=gCaji#-dD>b-F0)`adj^n}6L6wt$G65(_%2)TId4fVC*ub}N#CG8|G z8;&}|sf6Q4pz|C~?XbgP3DBoj5o_^Z%t5PYIksX(4*&sd2nUUw*01dbF_PvG345@S zmlTN5>I~iR&~*vKRGGYr!&Z3IBjF%pxv6dfJIzrl)`8t$g+OOO%Qf|GyK#1n*x9a! z?n*dP2F|g=u6CSL>={V91$ohYsMAodIQFH{qSwPo2z_qUJcjTnE0{5rqe>e*wx($w zk_wZT@y#A7CMf~5u}DdX{>el-tPg7{XbyNzS5pFDVQ48gd!fm)1CFGEbgdm#iqMxHxIWbc9Zy!9!YLU-2C(I-UsnpK&<%4T3L)dtgs1l-;4EL2Y*Had4_P`@L&w zdKMJJqq%Q}par$(}c!vkSBI1W$4yTqgEG)y1XwSzUtk74LTMiR>+sJ8SZQ|&OS zw~}9_2H@KP<_+rDJMKmCEDg`Zf@NTLs9S(Y4r&shjTEqXu&t5IE|DlEt^*rXVZE?Co$r*6hdlF zA3TkZ0+)wzRQ?7TGiFG%>u4`pu*qE~z^10dyrQQ8!XbM=W6)Eh3$Gi+oC6R3pvGcY z!AT$_7Q&4Aicmm7%M<`m!)}Wf$Pr_m-I$yX=k--uFjSKve@G`JkdMUs-PG&{`>+Q< z3A;bpw?=+1fahKz5sd<*bsXqtpsoU4w5XTF<2mBUor1rHDjE1TvWBWC;RWs&jJ>>R zY(917szKHXuZ>TcEwo3Xt8A7=Iiy79!?NP8N1$|^J%U9i?CIVv2i2`*t#s4??p!UN zu%N*eMolrD;fDKMx=2SBVDm7-v(No21(sO1O0aSf9Je2-W}te|DJmtGVCg|ZJCVzuW>7gYH>lHIdk;npOF$90+|QD| z2v^9<<#gwPPVvsoTwq32baA?q|8q#nyd;I3(q7YpjuSc>sbpAv9)gd7G@CPI%ryQs zYDjt_aLoJ(&J$R$t+>u5PXZ40^1z|u#nv;>=MuQnH-u;Q4ZjJaQJXW#4Rn5u-rpDc zBh+lS;O0Gbqm3=zs9)7F6J`9dxb5+T>R)7LAMVD-Rwl>>+!fkG))t__?E~u}nea@E$ zBiJwv2b50U;VeI=uTZ?laTC+|;b|=%rI1;5+o`Z_{mm!ll^N}}a@W~aF;aAQ*6hHj zzqMSu$UGx}d>*49?~Oh2Gc(#Lj5cEImm)55S4c`IqmJlz%%5Q%DZ+Ion~zRqB*(Km zjz=b6#80I0@~KJQ>GNsMY&jQofMFFW^E52N9dwcl*}}86Is#17N55VjTXb*jD5`F? zX=FIvXH4}$nud?O7xUE1U+MiZ9ROLhbFzWxN4Y#1;U|x|gJHkncFdmsStf_F+=&rf&jf#xquIEd@W_Zd`H*45vTp0 zu{mu&-JnVGK^=4S;htGa?+sg;)UpbEY6<)7R8|DT!!sAatjPqOV5CQ}*Q9=RS@fHi zV|ELoyBSN;XB)=NNut!sry0AP5M`SdX+7idbW&xh(uqs8MPva_&VC4JRETlfM6l|f z{&F*#mTg^luipH$_yCj1Jt3v2o&$c_qOXRLzQ@kP zW-QvlbTPcUe)MaV1JU7Ve3prwquP%eT3v$kqm1YcxlZiK-6)Ug0lRw?Pr=YPE5^*p zZH|p}+RvwRkD^Zb4kWO%np54T^jA~Mrf!)qM#e7O1xAeh)aXQd(L)jD`uy5Si-Bg(+k$MSKbweXeU)+dSRFVlYVIlK%P z!}IF1^qd+rlZ7)^$I|GOx-ZZmrk)8LaA>>q=m19M?2LL#gO;}~uz}$O zzHDLLJO^W$#ubjT(-2x=u#rjxXfiJ}dc1Ypz?shC4_k}7X0Gk0rE3xt-=$pwJ5iT;3#NsYNsJ&cpImYxB(SYV zNDgy1s#|OmEq3CpLI>s0yx}O(w(o^A6}LqE%$7N2G<2WyiU%Hy#&excRu$mO)V|lR zzclz-EV2#^E!6&NZR|=cE5?r>&#`lIUC=3|zF!rZN}o_1?TYj(e+fQcMJ+oM8HC&2 z`$<*&VwS(>67TI-j^-EM`5Y00tqr5_Ft{1|XsBOs4~aYtjoFDuT{3|z{%c#Yl2U)Q zld-;fWn;{uU8iA^lA`uuAz0{XzDhcguXHyBNm+mc@r-@(kx2^QliiKb_-bhPApQ#o z2&ajcsUq649vv}QG&ZXa+IP}6TtUCPR@1ZRmAP+fW9uy`P@6s9Crs?LXeE1conr*6 znpNN4*7W+%7sn4@wc~q@U1;fj$?SKsgF=2_dZ~n@pX07xwEF!qq2}5rR$XZce7)<$ zbkkR(<=D7U!G(9RSvTu-LMgl>8n?oc>%H%c*3a1bw=g-&oclZfFc{o1be zOL(J~(H*O?S!se)Yoda;2tJV$1f&rZV~ZtZ~B;3PKwUJ=gfVH3v zS_&2Q;97n!5vdSzYMl)H{SXa*RrF3u->q8<#I_)od#V1lrr^?Zm!Z70Oo*)`di>-b zp^hcU!(JmCq>ZWPI$(2L_lxLO^T0g5U;3xd^SEkSaKzn87CuYga}?)L6Kgq6JPi(F zwsKA;M4kl)4)LRNf^yp!>z~G3qRSNr>G_AVF}q%wD#b#p!>^B0P)tX|ZnIQ`qhG`_ zV4}PmQl!i@kLz{(;-=;1dL3z+Q*}Bbp7ql|-bTe`eMQ?cvG#wjm?xy%5LofBG*CD;~yu#@h|SP9T+iez06f^pSOAg3?-Owq_+Ak1MrA~~V< zN=gDE$$B%0$Imiv&yqbsu_bNBQy|R^H<|w>hlxHh!fsEmscqGttMq{0a(1H3I~f1S zv3>gSX+*;_%0@A9;d)uGwImw6|BX(gn7fC|cT5uQS{f>O((Hol0zcan)!FyXnA0Eo zDI9ES0dp5x8YyCmW?ntOQonwVh55w{%VxW7r|=JT^9(gZyF!a<~P?CO0H(T9^< z7mX2(a~96HqRk-wi;T|S(}bpLli{AQ7qqAQPs^Sx7uK*lu`lBK-+uv5^eDY?3uMs{ zkVQYTXEg?>=3=fOTUg>OMJMXa0}`ua62;lt_{4qP2;)sEm{5ZqpI><pN^Ha-;w@jOfWyAWO)FYPMG&qn2J;jRP~%a7Cw=1v6j zP~SfWDPknRMT-QLna(3-i7b=M#4?O*cCT+wDupLM*-wkiq>1BS9`l_oe;pb_-oLT* z;Gmwg{_`=%2(2~@N2xHZcU|*DuNf}6tM}BJV6pu476aG^S^Ioj1pFFPmgA!VU)pHL zqOi(a5Uc^|g+xx32EfWdd`E*MbGwnvEK5)FV@?6SeFfeTd{zjr_8>d2&8kzabY9Uk zvjf)3sr9oBpX03`YBW6Tf#B9K+}Fdiir!eCh_(v~F>8?qI|bXh#q9XnH3CPHYk1_s zEUu0xGlwpmo#v?LcE{L*wpEo}VhBo=_&!d4@44y}-!ib_ejB4ZZk=mO?p5}lQfaNW z?8oINi$V{f`}cCMi7`)$%E~QN_voZrDoO-*S zO0t}9F$zdrJE_e%#o!0EZhMlbo+gAqnS%2p;@su+h*wyPn}jBP`S_X0RZQm5ZQ|9F z8gnJ>(xr3=#DDt|#~P@pwaOpg?qumzGnwVxW$N~!ekvuMwTAnHnGkv1s z!bp$>fBTq)g1`=?eOHsH1?l1^u4-BBeYsUkblrG5#MG_`0Z$xMe5dKO4TP&M5znp$ z8vPPK=K++N;LRvK9^M|myX2&Z|uI>Mk5vgEld zdDlKT5DX#BJ85Nmtm6UdUJfsaFhfnf#@Lq7?a8h%KdS6$`f|cMzt*W~t>k^5eO(&c zA|wDU{??~5v6$MX#4o4uOa;CB8hK3EEI}?k1d)U%vo}dx`P4eeL-LY zy+##D@|P)@@r>KK1S6vC+0^dl0|_xpo2f8vP{r)M%lN5?O^eCq*q}dh5zGVmcQqE7 zp)nLWjcX138vr;2S2%X%(kEVLmQFyh&w8)!=D0a7@2049(dzK8Gl^7UP--bc4$t89 zOt)kJlCxiXBVWwt=TRn`auy84(@Y62bID%>|K=^y*V0mK=ivAj8^6ePeP5{uRwN(a zuT3?c@0VH`TN=ZsF^K&IpHlEXKk18|puRZv(2}%fzviwUsQ}r)o2O`I7wc+VRoH79 z^=BlZz|zBm=GR|)*DH^n{ly<+9)QVIg zTZ@kr%FqtH?=6)`-TK&c4jjgZqE^;|$MV@vqmeZt#<9Z)-uPACo@%gZV0tS1=#$^Z-J+9HWq_n_7;l*m&|G=fV zW#!X@x4LPSOh{gp&5~UE){>NDJUNxscPaNRyx)(;CsI4I;oPH>Tdg4Kv(ZFWj}Wse zy-iQDY3)8xxW6ngKS&Nj zj`EfTxV+K{P4miV97UO{wlwZ00L{5&k`;4s>6yvJ^O59K*~g}llkZKOf zR$Li46h)igf&yS+cjM*%3#t#ZA)Zr48|4B|{&HY-u4$J=3h3nrgRIBN!r2B*2u zp0ZiuM6(?dhf=92HTpHdlp#Gv#A;mp+SQfmsC6gUENjC3e!&1bd77EI?;4W-J*ZM2 zwr-8)C~ASa5>zmwbu#TazhA4F;*u(x#n!Q!UJ`>r0S=1zMX5OSLe~TTijJn=TaL)S zgz=t4AzowpGE%zJFvc?+I=%YoEaQz=+s~V|+gtUkH4P0dR(w5ro(`hYFkA{TDi_1O zUn#co%8JA8xcZjJ%?E>uX~-3*nfJB{BO;;zG{OB6TbYMZm;{4rAn0hOldB|0kYT zf*Am_Y@+bzTU2cQ*HvYd8Ki^`IO+?557Ks2ZI75#u7gND)lGxwTn_ zcaJrbxX3{0QpEr%!{A1V!0d#;hxW{Apu$j_tJAXwjfd5A|8m@~4J2@DR9#QA?xuVbA z6CRViiqo3k`I(b_>AgHuu!Bm2=X6py6(lQXL$LgfyS_t3C5(TLVkzq^U1E&aBx#DyH!RN2*!k+H}A`_K(+z z5}iQJ%Fp+>JWQ`^g#1MTpElle_uv9N+Ad)6mumUsUQI@J2f4`p=2K9NP21?>{h3@k zPDm&SUq-i_rM%PYcaEk;#U~7MCkP9a8 zGYPUR*cY+bB-T*+H~8Zi1KsuXyio;SGsDS*DuDh&oN7o6I~zjw5nnNoP=dw1QCC2=S|!h)2J zN_(_H4(U^55Mneg_uO zqPAOo>Q8u;tdzr}w3(fB4t7pY#85W*6YNGFKeX{6Av`>tu9Cs^cR5|uqAFIgYWR#|fkjZc07 zc{{{7J}Ld~)UVZ%Y(RLd;pD(Z5ETtd6U%(2U7>BvV08rOEOK8F%ZN8ievqewN zS1^*>WIsFkl?k$-H)UVQlxi*@u%!&Z-MnXTtnN=x)ab=Hc@t*!37qaMWSbh#ZIZ-=z&?DIRg3eHx$4pa;r9~R zX*iKZB2PL5>}uefg|Js0h-|Xv2h89&u%)K@j3O^LUxgTi$^#L#)n_iW7DFFtFf*jg zL)3r2#xo5l|J4(48m89(X9=9*I__S9j>xu*&HPSqaHm_c&>_!Yzzj~*369L`SQBX*HGCo?;A&v7$8s>JKGVDpQ8)j`hg@af1#dZc zzf64-={Rr@_fbm(vI1vb1+44@*-_y#{dS^e^Pxk)T4{>Ln)JX5V>NVYvQ8Yc;@i|q zpM4(nZm^0XjnwifPr7;8J*R{RhrKX-_7p1Q*q{ zDj8iFaO#?b)7Ai^B<*p8EC&g7J*{hXhhzKRrg-B~e|$JufW&f~q>7kEq`A5-T{z)5nzF;Dud0w=c53kR{eGSL)Jw53 z7(SIQxx_dkJw3Nsp3SE;xXC14%wH71mhNdYZ-czl=Bh#0<&UX86_&KSQ9Lzp*e^yENT|T|3!MZp0)p+Xipl5|XiJ5rAoG3Y8 zIvVnrsZfECpY$HRm#!#d+ML5qU55Q`=8{oolFWyTMAk+Y+Nd-tz=z=#9Ta4U;X@-& z)0Gg8Uz-cV^XdqO5-h_@{kCi}UHiPwqvJmva<-1P!|}nRStH?@6FWb}s?qH><$_bT zO@4^MHA|mJY-Z}vIHLyZuwQOZp&6-E9UEp$XF9lUaAE>pp#VO-8tqLGQN=h+W2CHm z`xF8;n}lh3n^tyK%c;h-0nv zbYEt03v;5Uih_Qhj5(Smm^%C}_d;f!_@gOO|Lz3Wt` zz(h9lU#*N&!7{m{XzFRTQMc-uPY=FvReJVSb!l%7loB2kEu76+p8ixk)@RVZ&j#;I z_=?xmH#%^RK#i2MD8T_YcdpXpv3C+QBGFDhQN4h z8*{`A-s=fc_~W{INw-%r2pxi(T>GD*>{PzIfV$^1eurXH`1kOWVY+K}6R#$voWG!^ zPk*UkHn$hj%}}(mT5#Qn26xqc4UyavnH*DJq{-{Nz%#l;(+4W7>#o4x0%betWVGPI zFGS1*oDvWlhNU-y==|fjr5I3lld^A&^W9I1&GH-b1sAL&++H!S-)}%RlDfN7fQ!Lj zW!7Id+Qiqt27j*e@$i|c6|`i#>XInZTalLQM=~)KvEA2uS6F`yJ}@=B+C%~zy#Tm> zHu#B0-|qm+_D#!@g4s3rIYk;dh(MWsZ%jMq^%0VCx~P;FPF-oFc1UuPDyG%pgBA4t zlY=rNQ$sKuTlp`UT+qv3;0%*YN9OXpjt5eU6wiNoR>fTT`uZg<-Jkgoe-c-t8P%QK__%P%e zO~(z*eX-E|=+}|Hja<2UdP%SjANw5ZuPo!oezj}qjz5>S`2}oMZtJtG*#_wLsfDxI zLp0$giRf9yHO-XHkq@$hBI{Z{;flAw9;$AVMTmj<_s8yw&iLhseEoDA?X3mBvwWT$ zM3>RneMR&4c=81j%)@@{KRCbf;RuFP2)z>{mODJKVrptbhHpRB?N+ zGSFlqGN?G^0?p70s!6p_(e0m=7o091a?DvGR@CMh#c9m_`+WJRX^R* z4Rx+OPg^!$Q>Tf_l<^|1J`-|_rpqNMfl-mQ#jcxlw~%%j4_cTgYu97l8eTMi`d&C< zEB!N3Si<2Ks`m{sZL zSM9IhJEqQN8JXC{J;u!^;Dngzc=6m<<3U^Zn>=aOZbl-pRVOkC7C+X$g(UVX!~<(_=s*wE4Pp&S_Nbe`zm-o@~|dt1z) z4tHvoe@`PO8YSeb=uzAY0>tL&G01HbCkrc;1Z(&-8vVz9=u0*^l*_K!&A@Jti)10m z49^b0D>yLkYj@GXf)Ps7*Xn6|ZaBvFeMeuaZyQC*XJY zYqplFZC>VB5oHSEx90ue?y+5MqX8RjSW`kN_ z=JZnk4q#5BdHDW9rE5AgiP+vGEfJW?g1U*X7M+-M<^AWVJ|+LIIA?|@&)aNRp+7f0q;I10ITD1T)=@n+hqNDzgf2sg-) zK7fY;5l!%Ih*O63$Y1#j+h~Ge5bFB;@&Yq-I^_NT&Y=}(-T=t3`Vk+Oi-K}|-7Rv? zm_-woL&+>hQrMIkxMkp*l1;_z%AL2tEL>dP3d*V~?-h3bNZ*E06BDM+z{qY3`Re$v zf&K)g$@0s)9$GP^^?liF#N8^>w_!EXng9_RRJ~L_$SpQFoR=$`G%$NEt={cV0d`0v z7^p$j4+cCuOP?G5Y$=#hVf-x6fF>eS(O^vV(8DcVeWdl|P#sUzll4_FWUvB{e+wr9 z@dc#8jPP~F-XmylLEFOX_LG9D<+g_Bbr=W@my|6wUiIm#62rN?;0CGl1At)nz3ya! zpv!Jv?xIfzxy9T}v_5P*7VhTpN46cioGlM>YW~7?eEB8`Y|fEZm{-K*;+%;LOTb%5vQjqBwA z`+yU=9X$B*G=8@iGElA2?VVqh9! z1=HiL1PeR^SW#WTHv`djp_yWdK^7vS0WNoTgx^DL5A|;IH(Nx(~ zM|@U;tfK9KGpkmAxxq;C2_kcB__&c17Wk`+C(vL>Z-TmEcD(EfKA=i}v?2>cq0dJ- z^n($&qXwVtERV)*tjl48sb_uZxU=<8(!p$L*m_07&%7#0{9qMkVu)QSidn@Qy$9RVl_VaQ;b{Pu~SoVd~10{0@QYf zgZCQ{7N((`9)eb=!2Q`zgFcKP$97&|>%YuBE8)&^ny8VdMVq{_%kGFe4ZeT+JD1f4 zoi-Y_pnaAS_PGMmE1?Q=KL4Il`K`s4W?{~ky{F02W(R)?`HTlE7ph}6-PfzFP6=mV zoZ*>&;&?G)yqXws`Hx-$!Q6>XX+240c`pd>$2Kp~l~cmq;E{!%I`YHzxYlU9EY6 z%kAI;XsOyWQY}uakS?(GntH}<3Nv`Pvq{vI?SPUBQoqRgO6Ut%P!NTqEsEuvfWw2u z@gO>)v|Z%Eb*=B_ZB0vMV~7LsszsMy9k1*Iv9qb*&vQcr(B%zrpwne_uV=ppG)M)$ zG1P-avo6dBf;AU#>c19;JI+=Ci-IjuuVh+P6_=tW(;|nBVibA*kpMuwTp-hv^j<%yu*2C_h_zno5n^qPP9P$Z<5y2(=9@?~ z5UN^Ll9R5$H0i7Z#uKhx)8Z5I?KL6!!DU2g7T(oRc}xI3fI*c7ut89E6n5mot%U8p z(36DPcj|lAH@7Y}hiLGhNC#avbNs}*e{-$dfGKlD#6(4kA2zqyb4@nh*g37cx=J-w z97B6mTs`a#-WP1lZo3QdLJr7Uhqr&>$ zgYvb&riVc)&Y(SH2?8VlYlAJ}voXMD>r?dDv{>WqihBL)3$T(`S(iGvEe@RBHZJ7h zj^DNDn*&P3M$nN6rp&X&7zH3x1;3K5R~58L>f2T#K)L2e-L#=TOf*Qa7X6M-BQ%dlEdwnb2u<;^- zaRz;!*FUZvhfCcA`o=ES7o^C@|4ZBhukm=V=dMRtCdPm)r_Yl7CJBu5A|O4UjW^hB zY{igr9rmq}-U1YDlIqxhO=`PvEBNJ>(vbnG7c=VIH_Mqmb^R+NqTnzX@pCWMAq1if zF3V2EBmGX%tMfUsle-LP9|*<>YKu*!fg< z1t5e_y8jzw3RGqPcf5?9@-(PU8rh%`X_XCAJXDHP+9`@YPSdJB_e~k%cBtRED+utt zA+c9d>Jry_VI&}d2o}Et6FdMMlICUP{MSJqTDun(go(qF5Dp7`1l?KYSHwx$J13>G}=J8_e*;LQUQgA&?ubLPx zBQ}AV39p@(LqG)=$a}2Ft(RLSFBt*0AU)#$o9mbGHqE}$7=27}e;(nN1u~iV8BLN% z;5%tXgpdQs$ekCMB0#oU-LWu^cVu-E)OftG?}jDut<9jo=^L{koQM` z&!lv~zCGPNe|cqDT8g20o#G?MRlp!Ec#klqo+D-Mc*O@IYa!Pyuu-8v2_u(D zgm&Wai~HUqILCBQc7Af3Spo@+Yej0M5x9W=lf%%V5cNKPMFTV44A3jW|2z~{AP=hF zi7hlYsIgmH<#zjz7T*O&wtO10W#1XuO}=9|6hBcfCU{NoVvua8)a)zKAu`X?qdy2a z8eqJn><)@Vpvo9LjZP*FDsKNrG|zIRgT{M%5AaSqv2f~Ix-QZ6p@TS5a5IUeG`1j0 zC4u5`;<@o`JYSn8M-HRqpQqccfSusc^;9W@+NFU~?G9u=phxeYNVU9q2fJqZJ?>|! zVLAk(X>VBo{E9uT5XIDF{VA%>IZPEV*8vo==Yj6MHO-s+O;e6$1m{SoRKSo2NMwS& zIy6VyLJGthNN4O%^}qxR>hi-D&A}o=#K)ezD)7U6h#~?m4H)9ihHEaNmE~$q^*2(R zH%BAo({uoahtQtAu*J+>Kzjd!r>=hNH7wkW2iO3(S02te(t~i|4Isg$UBuIH5Y?XS z<7`7lL3C1H+DMqLk9NrTJ`s$eyxfQUb;3mPY~Q$qgaxg9bOZ-Qu)OY@1!04{E??Ci- zU!O7jI-mapIKv{L`DC*sA2c!;#!J%huLr^mAXEVB>;mWxw8#%e?=H~Yh~V@cDI!2G zHz3=YePIUop-T)jJ0h5$pOtyIsQV6T1cL?1{15e!Z)I_Pz9LQ>DifCqB>HC`ji2ct zstNd4YBVtqNLN+ta=LJ$EA7aF(45-F5RvPi0fkXc#I+m zto_R$?7&$ApjSi;N^^<+9Zd0WRrvq^vQc^VFks)OjK9Rk*08XtOka-GNo3uk>)PUS zSAiAWpFLVzZzg;l4u`=C{6`tODlRZ8>a%zKQDl_bId}JM0I)o9G@TT8BcimbAQSV@ z2DEJg2p2&0AH)U^u@FQ~gl4~sJR)l$*=T-!M22_K`fDmMd>JCv&9PaYMy>I)?=Gu+JEC1JdWrbti)b@i$szsq31C9v+#w$x4?e?J$tiE!Mvc)4hdL>e_22PNXBaQr(t zu@lsV6$n=`CHkSg*+qA^y;ybiFklACv-^ygCqQ<1BLbnwZ;j@F%K(W`Y!I%Ba65Vw z@Q@C)Ap>fpa^v=1oi!b+QfYE&m>)DeKpQ}a3BnS9gam2{&q57=4$z?78_J~3%>Y9Q zfsJJfK?k@*v@@#qr+c;ZzQL$b+@_ofbQ$z$z*XW7YSBhTz7s=0UIBMY*guJ0loA2j z+tCy>BxM)0)pr2OAbA3US^~oK5J>@I2M|eg0WD%9GN^{aAXtNfg9Z8pg$3->x1Mr3 z;Qu_vj~&&$CoTqs%oD&!FH^Obiuu@o%QqD&P zgYp#Iog*DxUx0?_@z}w#vz9O1f56k`KaM_e^6nW;&u+MgS-Jr zfOJ5{*?0G)ZYtOYr~=RwS<{RB9WN#pl1ZgUE8+&)Gj`>O{R+W&WRSd~03(n>wgZzw zWCBTH+Rsk`&ZdMhNzDroYYhGcG%-k}0k*P{OHDDooMSDgOiGIq=6WTBxTc;mgBadT zm%2A+DpFH9G3{tCJbXCuTuF;lrLY_DkU0!yoS_aHJ!yJMiO+j}pV%b-W;5)r1$#L# zj=322Ld}?VJM}D7iGumSITh7c)@%hgXIBQ|z+;ccuN!d2TaS`V|XMY~v zs>F=1U=hQopM}@m(=YmStgL_F__f>6-C|@9_cblHts1cbDhS9U?N+TA3Y&wRy|m0Y ztPwOrUtT+gC|Os>riG91;0u^NvIqFgaZ`kS`l(9yDxcz#iE+YFM4N#s+~dUJ6M!D> zM?V5VTJx&PF<)8pg4XAoFJM0mFXnunI;0LL>P%-ruo9L-Dlin{bkLrh50wMd6hu-@ zy>Ygl1!X}?s~m6wfqu+ioIZTG-YN<=3$fwC*i4Ln0lt?1-8HAuP9q<+fk)ntZPsCzpgF zO`QHT&N6p#BuU)T3CUY2*{}Lsvbi*8(B1YKnTsO~7H@a#bdSQ%Hrua{f)y6kV?xT@ zA7(I~{bqWy&KThrBZ-TF19cAC1)BNqSFEHDJ+qPS*b#r}##1?jsqc7`Bf$7U(&}c~ ztU&(+q2Z2$?QFdR663Na2~_FWoT-?5F=Ma#DPZ4Tfp>O54^&k}F=&SRRA&g&`&ch< zt_SEjfLVZqBk8;VEuhJT1)tq~;{%Mf*Ph%_CRN>3^bn97kh!wk9lVN^qVuu6#{Fa) zNKhq>%#0vsS|uUDVR}6YeH+vZ{{WNtlOuo}K@b(id7jo8gXpH>s4>)k&AnpllaRww zfh*Uj9mLj?{VpneH&bPuE=6Xh2e{%y5mjJF&SG>v;F!V#{3Taj1gN9rtLUAqjX!fo z?H&RhRj`O&?01o z6Q?r=n6g3Pb3B7V&1`H+-*>Kxock8yjP{WM1A#re^G`Fytw8ATKYJ_wr-#qRyRDj% zt7F(4Nc0$2oW9t+p6D)@Q!%(L?#?P+R0QBj_{^7DhKP;?JAeu?83#DoreZV2>GOs81zTQSP_l ze|QcS9|3`(kQ%l`SI5QhS6yaz4jDNe<_r{%lQX{zAw*M~rVUsQFWiQeaDusiGZCpq zw{liDcU!#)2A$Fa{9nlwUWBNz2_0GTF5Ik))X9C}m$-J8CeQB&KM)oS`aA(z>rSD1 zv`Rq~*an$vGp}>c-lf(loZ3Koa=`@~pa#7gN%!EI7G5#PC>^P@iwiqbB`m$|ga}bp z+D_EeW#`f~-Lryy%Yzt{DWL@}Nn-h0o!{ke6&_sG-KyPYBaW=KWkPD=T_28+9QR0} zU#PXm*fidHD0>0M`vFX^hDQtj7a5ISVwzrQAAvi5nM2zJ6d94ElV3Y-Aiw9t0eQ?2 z)fpH$3m78VoyuhvA>aE={u#`TjJ3m3S{dZcwheYGC^n&Yn&7Hkb2c}a%)>VAmf$B+ z^is9~?${zw2-BSiWKWo-Hs+%{+YOA_&9hfnd^-F=sWc9)2Jw<=<+`z;@$SIE5it-D zLSCJK*L>*Anl)qXvhw!gx~kj#B|Ph%!@d=GpX~$%7+`arjTVD?NE={~BaOW!UsFKH ze3~j5w%c-;PDQ(dE{_^i*JzO}==y<>2r<+2D|8VNEdR;FZE~X>ude55z_08`)Na z6m>(^>AA|E4UnIKCC2NXy*g!7$kN!ss+6O#mxIX?xW9-aP~Ykk4tYY=-8< zHV{3OF(;hx&#Kl0H1E;R{95_uu-_Ymq{od`w2jQLyTz8*GtiPl%t|5CDJL3Me^faO zJkuqv-09j|i9l%xWTB;-bu=2R_y1vbB&&H=Y60+k>9p=xEeQ$T zcujNzdP{mB2jIu4x=#`MSd{op`%NKG>yMcfDi8(6wM)D1N;~Md0}2rQQ!I@-qiLJYa`wROGjz#rMI5uCyKfUC>yBc z1+k>7V0|$b=-1Du@*XSj{lf<-Ijfjq)Dd-j$~P zZ-M4*zxJYS9x6b>N3jCJ{y%z1vO(k4Qd7iJv~z?sz^0!%nxk^2z5=9d;1wSAfx z5k~#VS^9w5d-;HfPwBk0oxQ66qznX#<@?LAg~2or))&eggWu*9e{$ck;XSj5jW9m z4!QD)!WqfBfFEemXfE150`s0SJzss`YrP!a+{zV&8c`=$HM4U0u*BnBUzsd)&3V3J zq(TWuT!d}1oeIF0cH^`N^PG?28@Lyi#P*{mK-Ll}y;BIoSkYvgN zD6%+@U3^}W{ufFr5N>?m1}!lxg@D^)wSaX}aA#mqMY%+oSQ3xo7QmQ~00!E4kiy&9 zt`g|XN=79IQ_t;%-D+?YN?7iV$#kDylS1Fs)`uWs{iJtd?5R-)dmgZ|lGxR2pxiVA zeR546AUxg`&XijFyNI%& zfHCT?&+F+?pA8(ukAlXg6YvJ*IlwE08eu6s$LWwWX3!f#-?6A}UKCIwne$fR6Sjo-M5 ztFK$(s($p7wHi_)r>{jUoL=d`MF?%mvU>*fVr7@TNF2Wg6}c$zmr`&b@7yn?8*R5P zd31@dlq4rkF5W0ea7V~E_Z(l`XRB+yCHM}J+2Z{XO5-X^C5JT1yU|(2O|y}#L;j{m zzBB14SG72sFm6vj7W2Kgzx82Je`lK5`y^^nI(~S$SbwgzJ*0x|&(p6H$NQFzR)_&g zm;n`pP6ZNY4?3Fe5*U1okQ>^iMvVKmC1<`ejoQWE0PPX75HLpUOyifOYkB)P`li1X zAO8wG7I(!NPmrCaI+`4|y)JvoGvxb9sSD0yb_jjv&A+}=lcHT3R@{48V0XQrkBQXA zdpJMQ%+v9=(9XT4SL%&BcaMl*#1N!m)88^0C-Rl5yeeCC{Oeo%$h&iCb_JcC!P_H< zW$k5*(Y<{#*l3ITDlCwXs?Xp`w>}33n^4MnHI(PTQAaSHUDz)?UZIgp0u7T`qtS}&sz{^H}!e8B+QE#fN+yO~x};XGGWIJeuhW@H9; za`k60mA1R^k)4-~zTy%~woQE#7C%eT3KT;{uxFBxCCUJGXg=iC;xNX%3JBuxBUntbBYb$j4nPh^K}h zSvUeM-bYS5cHy33Mys!a3%-fG`MZ2p*>?vK&HaZpU(d6qU6Gn-0NW7!Szmyc=sB3~ zViCPz(Op+$w0f4C>!-Ca)uc?&uSbiqla&(~VJBnS)?XKxM^{N${+<_Z?WP{DT;gqo z{*Ru|_P`+EPxn8KU;o}=cJ?3z4EEr0c+H!wHtJdj&eZs)caTe0itEyLkmcTq}`mwY=b#3a;0j8rR|DRdWp{|ds zvrb6UXHIP<>n12+xe)2C6Fs!`7!y(5!*=Ld7pI&ei0h^!wuS;$f&5F*o)v;5O&w1jR;1w2)Rsp-#aHU~9H*W>cTWWU!zXnZA<}x}q%3 z-37k~)tEAq8^@ApjuaaRNuy1G@KhOT8W~3ZF>J$iI|s{qB#;`<>ix8+ArkN5wPB+)K9pFtE`KaOEjGA@ zqID^;6;N*;=H0>?g+C4B_7GuHvYlKQ`Q6I5gZZ=g6uuF;OLn@FB1xRLfR|#h*}uMD z7qPKxxA^PncN>F<-}?9vackjx#>a>+-!FC8C8VV4Z*5sEy;9o!Ui4<@8w|Ge{Q_i( z?5%|P7aKmRz;ru|f`_|jla3FG6gYfdd6?ogRBhH+!(%^V&AMoqkGa%aJUR6jN=Od_B58Bvx zGG_uOAU}U4blY32u~|@j46R;ew~S7OhWYp{!>iif-$qYZ(smohrelG*3Jzy)4)zv{ z(-4{!begBL+$LzRevyBgvHpJHSg?6>ICbF8VbyiBgX#TP(Mnr;G0&p}_jLDO`F-ih z%p=U5$BrELMaB8MZf+~(^f_y*@q8fqb`l1{gsug6rfon=j++r(?SmW>1v%TpHjD3f z1+WU`yHbB-zb5(aV^6@eQhhSUCD=7`n(fm0t$p-Rja<3|&1@U^H(QA)$s@Vuh2u05 zJ9lgVjoAe!_fvCE?Fe-Av29n{8NHA|=XsiP^=zjXdQVs!(LE`6(#PvCO+K(iH7&u$ zNCpeZaFSuRKWP#&{r}YV<mWI24h1)&V%8Zi|9AydeIN2I|1kg9ABpaR9u!7M0j zu=Xv+YqU=3;KIBOff{$Zx+JiM_=ABfIwrfvs~*rK-hC7GBh+DpOID{O^&JRhg`w#? zZN`Cp+*-DG!QVsWPoOCqBE2-WA;`NECPGQt;%LRB0IG!E2G@>fNJrA+cBQXFscKG- ztpWw=t?CTs?opHfbz=|NF%y>;Y=XMEgDCg7lb9N5kuGe%LE99_Kf=#zFs@VER4>D{v zw*kiZ-@|~BgPs$pL^=Fsz+}kUpq|ucs71A7Pf#C_vxdW8ki@DOKj{d&Z zJcZ3pzFP6L33(HA}>>QPXT#WOgNvGtFoyn)T!sE;kz=UW#83|ZMRufle z5$+J=rJyh{O%3pLeHWY?8#b`~(*Q-`CJ)x4!#Y_S(V8cAvORl_=wS@7Zcz>YzJ z^Y|}5LfAc==C0^C%ykIr3Fg~$bgzVI|E#0`flc0C0K1? z|F0|mzxo(29APLVwsv~>~(bJ>C%47tPKJOVpy>ugN zZE4)Shew|W_v@;~T)yx(QJ6VIor)#fOw}# z)m^E-Y|+Q6Z3wCOL@_~PFaBA?3nW4d?OYN=E!qD1Q-CEVoBHz@XkaV{tO*=0|6R5G z|M~Gb8>_J`jBoImdd&d<+zPx=9&M)xaR zAu+Z%6K|th+5meNcRQ@$z%rY5rgMK_eOb=WpU-&9(VaSX+xr`=%n9yYnn0|$`xac0 zD?l`O{7#yedfUkzg=^*j%ob+$(2@J^fwb`zi#A1inbh#ZKiHNTgpbP zZTR9DpQmK~U?rbupQ5Cn+v}q?cB*&6hVkDLjszvb{2aAakRd$}`R9UY*OUqLti`6T z<5}F@FAPS^rg!UdF`)nEZB645+WPkfb@hV^5O~OlE_2O2b4tAEc(Yjt&xJC#FQd;?Z=8m*A~}`_wGjTjnQU^f*GI@LNtnx zoUL#pPkw}Tu%&U<7D?})4W#!@JTN4yp#5~;|2#upEkQNR+HULt@f_B)+ltPqv*83C z0&MzF%j4zr#NqGp)1tG1IVi-GM9RJsCP-yzzKg(dh#AUn5}FR>0qr@~I)QZ{ffjJJ z@me3hK@{Qb0?Wd7mLn?ojmA{#wI3?oHUYisCW(82u0O7yKS5r))^hect|hFfeVJ3k zo7VkcS(Z^z^z|OuT~n+YD$>?MCMbsQ^^hjXwTrjZ+NQB7D2`-W>bvi%-JS#Bg;P8e zsAbPtzWlSzuqjr1w-E)ma5}h!??$Q5h7%&~V=t&ZgxvjzKCBH*sZ`7@Q%IBE+fPpy zu)*{?NfkP%%x%$Tn_A!%Ju~Y|znSA4s}qh+{G&U@bVe@sg8pEwOyNzPWb#5^_t>GJ zfMEWSQNoo*sYhc+@HHYbkW%wFoYg6(s~3_xiVXWO+el>V6;0y744Ge6YCA!4yf@|& z*{`4N1lCV%fkhFm>7rmiogAJxvX7ail~?AIp3Z?Q7|rg3je1V!a$#hvg|8(80|#qk zL5ZEA4ljycjs@YHMF$pGv$!8NiEhtYRiYe96okO2UYZ+h@Q-oxzt8%pC5v`6{u!XI zd#zxCHbc$%sD)OT`uu=eKi?2(+LL*fGi=k7@w@CJJSe-LN_8<}Pj6QC99d!{7*(a$ z3=QwwMhlA_OE^`6$IW?u?VTvUnYCGNFf)H^G!U0;MLEkS(A^O&lYYX%YJL0XvrA;{ zZv(a8O1UozQzUa?okp@#aM#hakLlYF%#*7Xh2t!PLmC#k`jM-~qh>m;fiF59G#<

OlLO0y%?eAM*>!iw2+}6 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 Za71X^iM8Rc+>6Z9IvR$z%hc{h{tp5VOlAN8 diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 2c2ff9147e7b2c32f9ff7634848050b15a659b1b..14fc626f5bafed93ca34c2410a07aa6a21ee26e8 100644 GIT binary patch literal 72796 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⋘f@X&=Qx>4{&XrPk z^-8V_^*6*anByNYXwwjx$RZF8qKT+AGd~GO?WbI6s0%K2B82a1Mc~2=wMeq8LV>An zZbKv|12qFei;$C04W&Bh6m%;XZ#k^g#nj+$6(BE5J>1>s^T>9<8xnA^ydW3sBI)>$ zMTyTtCQ;yO#din9mbb(!ndU6$Z4Q&8k){j~HNey1 z|AN#uXwsp-!;0vj|4T)bik(Fzu$Roi3^gd3R#b9Dw+}`^PQ|RQ>Jf4?k(sv^Hkqb7 z!L38E9poc&Y%+5ei}R{L98_`&OgD{eg??04Vkuie;3JAtB1FP#IL9C80{58>M*>bS z&SQuxk+V-T-tF@2*c6A{x^2^1D1xubV993=rtDsYWQY}-2ZU8Xj!xM%6%39wjGB_x z<4_%_+f!ySi1(q=e~;mFxmj}r#~K9_wRWe{{=SfAW{t)LRBDX z6NE;NEKo5W!j}gAM6*W!;|wCVPjHdRZ8G#SFmhJ?@J@a9gpHS$TD4pf+T4?4@?1MR zl9f3YORba!21a}aOpj3#I^gn>Qr;Jhnc^hV^agn;hWRnNh*7DDR#o6DekgsPsJYTL+_@?S3VWG@*z9K!znar~WQp!2*$hG0(9EpYh0~LydaQy>?5=@71YW zbLXc(D1OAVs0Vq|TE6Y*w1^GrGb;po%WtcJp=Uv5hqKc%o9r2ipGYsXjzOk`E z`YS2t*Yl%vYiKuSH_))Tg)tH`U~>sqV^al~9__?z9+^uPKz-dfq*#>wGk(q5vKu;~ zrsB3j6M}PgJ`Ph^K0-3L$viTCtOwNzfrF%aAxcMZ;d_z2mw_ul;a+thR1hisBag@s z$XDnEm_9-xbzD6_j)S3N;T@)<mBQ}p|%#F+E+ ztp>NzCVm-xBn<4}v=xL9GV)I5p!pSfT^E48Kfxh?I8fj!WCptuWzeY&s5O}#e1)FKM?aht5gqs^A{=MI*h~`}l2!wqH^a|Xj9n4#$Uh@^3zHzR}@ zbf5q+$46q(v|GE+A!FQ0^a^##brHH)*UB?LkwIO86Om9h~3kElTpA1 zsiEPCSVsoLi8c0)>2aP3`s{G(8~2UD<2Gae?Sl=D4>4aT;A07KF0zMw1PI`b$>AQ+ zg08;suI z?bebE`9U%!8RKSJ5xe!?aU1-g1e0w7-f7~!wV6)}lwpyGM&uk@CEFAC<>+Ymm7#Tf z(jYT`#Mt?h6y)W^a7}-KtShd1K33L-vu>Dq{k~yx$c$gl2c=Y0+{h$D?HF-(52?xM za*c?L=K3+2{8{NNRPxt4QRiM16>Al$U=KLE z3;0|~F(06}wH`<4lG|fa&*G}X0(79Ky^+9l2!&w@z}SQ@X$3fAe}xrA<#EpQs>sRI zyhZsUFmH#}6!$Y#_QGs;r#LMldjhAwc4myfN6u4;Q3cv}PIt5?Wlx`hfTsq!snNxemi&*a)w$sJ2 z+Nm&d+z%jbjVOD(hhEA352yO;zFk2WCV2mO34K_z>IN#pD{kqPmtaiqzYe~jQu-l$b&|mQwGVyIq>r*84*J1n^cG|K#W$?lKm052@gtY{X z`Jdbt#p#yqV9;6T>+k(V__9iqgQQ7zVR{I7DtU;HklP&aJsXau+jL_yoH2Vrv z7~`sm_9nG<)<{*@w9joge-BFF)Gn}9y`;e4b?7A)e^R+m{0?fO$Rtc#!5WCFnDqD| z1JIzt*a@{1M9VuBC}A`I>#xx3p>fh)h*Ma~$XtDyB2JEh3lWz`7%rLKXMd+HSKmqS70&2N;$HVQjZRi3YQ0HL`7M%Bv_6b}Ben>k*>Z+z$XQMDKo8lcKx&mv zNX{K3&?kFR%)L(FpSy;Ke-I&)zz7XU;y?+ID-N+bhV0_PJUCz%rlTWCv-q%6gpxpP z2E2DolMTiJa!>5d#fVh=wQw3P&eGyiprZTRV%JuTiIG!Gmo*(pD1guhJrUnf_c8V# zwKEZ!BT9B(zwbkPDLH3gavy@lwv_HOJ4a5kWpR{IAJ}vee-ych+ zD@LwlVFDe8lI?^4fczvKAw3yC2D3;+$Z)cniD)ts;bg)--TAs94yQn((7L^lj4Wl0VR#K5DYfSXxV^}ACt0VtEf z3hP3b{b_v$RURhCbd0yL1Oxgvp<7WTw|~m4!HFH<5L*uJXq~qOF&cWpZ9)Hwu6F^8 z>P+)}3ol{RZrO7<4BZ`t-aUJCMzRI(LD5QgAII4{8cZjGic%O`jaM`@LIuL|(#DBC zku%+6Kv1ybB$^-^rYNV;eCoX0#-0ikNG zZ++kU{@?!v@g0M_gyYf`+E4?2c7G3#hVa^%-yQDxg=i`THL<_acD62P@hA5k0`z@0 zD2mhnj<;DRV|hUV=L^^|ht24d`>_E?Hu>Z`uGAgEs)t(1)NJHpt_|>+k%7~uW8;1E zOPn;|ykKANqX|QM+$OW%BPdkdv77g46(bR`YUNFPu4pH-?)Ycwf7kq$F*~kS21cN5 zAIDvT@BU#Jot~@C3d|B$3tYA^Q5+{x1@BpN;LHX^oIy?~s*5SOm%1a@Oe;;&BSpqeHy%9lbHuHfAPfy9Z<;S#(XbXd8cLSFLK^1ix%R*?A~Tln$J|2|I7 zp$*U9Llc09N3U?&sJV3HsSK+aNS~$TmOU0t*IG4yY z|K!I}MH~Ynt;MLDcLk$U@Hd2X?&xE2Nr%c_8-7i!LuIE~JLKWW{InZ*BUkh3`GNOq zeUDpdId$Ls^l8kj-YqzZuVSi}AMB*I^fswY~%{^){dmOFBN!=5m4X3Y^O9tw=&=c1r7Aj#HxDXUh&WEFBKV6^~bUT@YnBs9-12(L#I#mE7=ks5oU z6)G34U&(Q2&4EZ+lWF<_Bv-cmex-i}=_C40>W>-|yMOF53azx{Znd2>UNv$uNA7{zco#oDR4r4X5$4l$- zg5P`+txc|&aelJqyQkHKCJ!93KCx-jAKe*w@kQeat~}SG+%=JUP29cH=7M=f|C;KK zQr~)iZ=p6x{fkJu5v{)H+hSCWcF0OOQ3*n35`eyF6>oFC&oM*fs;{(Zw$fIg9bYl` zOg2I#Lfwuc336}{R8}D8(E(;FzC{utq5WSw3>=dA7+|goca%xHXS$X=95VrZ)sXbj zVRvBqQ7TjY?@7u?#DnV&EJ$(9kQ(epCyYq=>Kv^+O&F17(KQN! zB6BDiELon}9{+ZpwsK;YXT69T_u1d(TV%fHb-?Y()7Gfc zmhCpI>$iRSrcH14MXv6>o9J9JZt>$gSGvq@n>8S$Dpsy&^*sd7bE61Vn|yCSZNh3t5Zxv1h)L+;H}xw z5~d!bX;mY?8NPKqk5C2*C(#0@@F7@^Oup9Lk7W`82*g-_iA+>SO=sFhyYM{aXWD@? zuOY$+Yy3@r)#i=}=h4U?*z^WdOpxZUUADF5#i1@D6(v`THujXDgzMAVEGj4H^ zyJ^oNoDNC=<2z=X9u?)pH2eMWLu2f{n;9>+tXPwr1!n_Br}%#%PzK z(NiwC__-0uj^#10J)J(*I2CJzqQlJBBzHz2Gx_y`Xt9O3fk2clSw@^gU)7Hu?umRv z3=QVi=?*QV@mDKEMNLT}X5ZYFB72DLG(V&N2vf=&&+h}~i5H`xb%-xtbM_N}OjvBn zLFkC1|AQ3;6xY>}22@p`y+&O(6CI);i^{%sbAfdiH3TruDfX?=B2pMK0YqAyS2pfg zB(Bs)n}#V3niL}wRZS8!4m*=!9Fo`pF#86rh(ozcbOFR4TiN~LY>UD6sgLedcXS#t z?nE9R%~#v?ujaSIwT;|Y&L-c}49+Q6^xLJas4}EutT8-CZ@kss_zYm-64DWvK&@HY zQtdugbHJ8hio5r&JwH*`Gn`eLznZ_Y(KThGLyk4or203SHoesw@=U_<2;UXCQ>LgB znGcGMUqcUa`1V>4Ct3OrZ4avm$yt?<-ay^Zz+=B|w0cq-Tds6eTmPkW^wuRUTcYMi zp{U%KsXw*RoAB94G0cVR?*cY8!#TGH4@r%18LPZ*Z3D>^1kFw_ZYP9`c)(>d5*e`#_fa3I>+Mv1tBqOx5(- zM|aXfYHStzGW5H-&ixKgsdvVKNFHy*E(MXhTbn02&IeOuj~DgfpJEN{K_TFp#|J>J_a%mCY1s>6(?F*%Kf+8yK$Hd zI2`H;bNX)?`GN{bofeG!04A@#ucZ!B{;}_3Xy$Fh!OG;s4-zQ)dq2qTTw_|NzsX)N z=3*3OrTA?qb*cCBu&@A2Nko~$k${zzGI%~gt6C-#X#Q{x0yIkiV&Q(*w4g95reI7Y zdYG3DJkIe4SBJ_oQlU+9v$H338ckiTmb!;E_8%pCOYeEoSp;R3-SeuS)uxt=_f2aQoD;7x$M>)PCru()*b^1jz2UY}N#Zq~1kvG?Y}qkPv~C^b*?SIpOHKCNBXJ~QEB zl+guOq8X1bYj^o-+2tP;>#uK(XN9KcK9jKQvzx2Z`Eq-hN%MbUsq3h={CHbyyk}K% zzsr2Ukz*9gQRB(Db>5YbP(CM!zU%p%gW>;c7lt>hP+5#@XsLcULaRs6(H#uWttD&I zs`U2I6dY??Ypg%tTHMvlI^6Hw8Cjp*{(MPV-^n+xgnlzzt;fS7VJTfP!6fx|ialcTq*bW9k&0PxnR5xm%!_hzAu+$w4}$~ zvl;oR`lI+~?#eo|{@j_AHpf$D|H-zds)?F!z+OK?dpbU?5yt=ZPJfjtyZ5LzHQv*T zbGI2Ho@iJuSqz9pH;R97G0NVZ}pW}UO`N=r(w9W3@ZJ(}iZahiFdQ9{4>3*g>Bh{yS2kg^r{x8j9 zRVi+>DGKQxPH+e`)~YekM(E0H(y6JPk2?D7YvLvQ9JMau!E~o_)HhWan~EtNd~D!HTd=Zw;w@0y8huv%c(HN7eBT&&o3o zs$@gn;Vm|%QiZIa2#=<((w~?HgI=n&>UEG1sOw9XC(JlMmD0j=advLb`uvpimLoUb z>WWHhc_pVX$(A*>ak9A~yDy@shB6A!xgvz;!R~L8MZM%u&RYGKk2@wq`Ev(T(%X%d z$-ho-@L`8p7X%j3=j%-^JJ&j*>+Za?8yyqf>?`bHH~a%;=Wy6*34OP(`0n8wu^{&u zw8JwyV$bYunq7WrL9TN+E!R=o&JoTq-iU0e^$)L)zqmle5Q|&VqWEbAYB73o5QG{o zyXlFnsUc6A+1o0C=cfzj_y-qX6rn=1=Bx2_cN)`eC`;={O?J#|kdKRRXKT50Z^#{N ztMycIerC$uZZU8?Z#5SR3ICb9AK(=1Z8z&Hbx)sGsQ!WD-x_%1Q;pU?#ZsE3%n5e- zc1vj_`ydB$+QbshWaVBWlvmapzW^w@&88}?On3}(YD+rC!SMQd;^rtunPBY#DP0f; z;}Fm!n#~K|JmV+Z#D{06ZB|2uKult+3FlnHgGZ6%eL1jG1&X|T=_r2=<%NGSld zFdJo{A}#mgAo$`GMzO-7s#UPXR;hg(SJIh4T~Ek}3%cMve{a9zCx|?U!Sa`z788^a zx7{nZBl?GYlQXaBF4lA{+$qTzLV|j91hsK%$m+f);hWYM=X-Yv zzIF6uc71KWxM@C*V_ow9+Zyj(g)u=Bv`_QR z?s-@PBeAM;u}gJ$+a0U>I)$O)W9RLDPCkQc^@~%ABVD@+66)eDIEtr3$#0(tn@o;T zwZdZOOJT9eF}R&&;YH=~eik7w#dU7Te{G>lienvED9B2Rm>@oQ-cEcWhj_gypIo<@Io*~e*{ z-g=O+y6;{pboTageO-jV-BJfXyYR;Oj;oI%*sGs>4Xl8-Yx-DqKtA-O8&fFnTK0zkZt2kt9zQY16f5q&?$D> z4;WKJjkbiO=AXBD3p11Vho5LTfEfezx0`M_PehSG_JH=#-!ZX);w#WEB8|Iwop&vd zKS(Sef~%Ufy|wFFMwjiWESQuz^BlgK^8O;bzHw_~3(hx{JwL1H_z-S4W12vO2*y28 zCW{Yxz$iE4;IFZ8D2xbT`_Zu&1EtFIFNtA+o*1eiMiF$x`C{WEF8XQuG{%Q`i2yYg zBo>vvapPbVN^X4n)-M=JObZ2P)-~u;%kU&q|!8R3c5UV2=D@aGLKP zT!so)qxR#62iueTPa|c%(?5(7XM3wn{{qy_HHpo}xOH;rmX;{{B!KR~KQG0f_u9@_ z{cawV^u+lmv)V%!19a`P_dJWzps2Vnr)^%EyiAnYK!;{B9F*;3> zDc)cD31tC09$Q`1D?!QL(~f+*?k|3O#GC>0RJ1SG*axDJ)r;bOWyD+k&x<@DFv&qU zN!ga)h#6#3O^|FhPuuO2MEN!T;H@?@s!Ikjj#a2J9pI;_SWPJipjJu}*wkt=EDnBw z5%CZZ<>M0+n5;nc5ZL38mTxKRwpDNq0k85Tg=#IrJPa}4qZKG2j!Xn@Sbg!lgBQg% zPVP3Eu{W{%uf=`!-7U5^KfFErIMs`LH_ar^q?mntLiSIpF*RhW{Q;IlT`#(Q$EA$j zH$Dcl0=kgsKjMQLQkJzTkIP|k9<_T*tn{e$>a5=(jSZKSQuL0e#Zj1{cf})?dzxXy zAcW>5F;?aD7usd4z?`ncJ{`B73ST)q)TRk_2h>~`<1=Bf2E50%(z-x$U<4~%L3!>P zg7On5X0^`9S((!>zQdpHzomXz8l3WkMTg|!c;%9siCv)-JWRN!#=G$ z8WJ`GnO6dya@$6_(S*q(W{Po%U6_Kwc!7j0e~?sk~3aSW!K*K;(QQg%J&!>(J}8ONJkolbG;+#Y*{fz za!J#~ig?dTCjwrn$i@|w4q*>*NvHdaY^M|#+r5ZTp4-1>32pGeZ_TS|ZKaR3;+^qr zUZBQOUM=@h^>s>_6#nm?^ycoX7F55!Uiw(GzurRS^xW08Ns+gJQT7paW%A$Gsd#%NRdZy_;^u`W^( zY*6l&&_RKEGhYfIiFBSkZA{CGR)N<~1^i`FhUJoI_JcyBzkxAIWt|JKD#CK0V}uV- zWYG|b;y@An)NC!6YO7oXN^3D}9m0c6^X2mFjQpWHwj$oUo_W}{JuP;aO*OVwPc^;D z2{P=c(rP&hIkCj);|~oW4>k@$2kS6~NFI<~Qb+vunK3 zuVBQL+S3y6-H5UTt*w`1t6Gxhk|39n!k6}uuUU{+=2}x0(7iD4;;y)q%%?o853YgF z_J(k#Evxjq^UB`5WGS1O5#qjvhJG>(lwxDLNt)n=$OMy*NtgWaAGCV5tU8dB+Hv3b z+Z}JKF1a$s>}xCjA-O01S!wp9;+|pqEPW~ZG^zn}f%Lvkn`V?-A0T%$<#=k%&u&wd zXHNN?wh`C}RQZk~AIP9TP5vLiWq2sE$KX-x3IcgeWC^e&;OY2ksSxEu>H<+>$~3CY z@!^or=+BuDdcu!qj+@Zfw%@Fa%0Y-%iGC)(X+^bS5&&9?*A~;C0|5kX@s`WT1p*sE zX;1M=*{QKBT?+~>lv0SgHo?8mBH-WB)Df!x1ivG*=W69oYk2JYZg&scn(AMFVwV34 z-(H^iOC1hl(s6}vtv-FTE%r~ba9I2PLF0G?c>t60q(hdPRdlgqEIk5Jk@+Xt3El=$ z0I5g_1}?)A>(J26_zfoa_cHa?oe28QA&VQK`58{+8u__;PpUJ=;lGS$5};a=y)W3k zqq65&`!us3Gt&^wo#_7Brr#Vx>6Vw{pCKp1UV;h+0R$igGoewwLT^a<%{DBiIB$MI z=TGe}_$XE?vDjbQxZe3PMi!?~AKanQ3tft!y->AsiLW9EgFlLZV+hPQ^Qz=M<||#fuPStvLLd(3*&!v(WZwr5dEj_9{gP7IJ}({4XxL_W-)T zRiTEh6h}eayMEUilCyTJq37NJ=#izBd<@RTp^D&b(sCEw@>S)oWe4{S9i6B zXoFwgs=hqI)e)4^H$tmRBh~9_UFw@t`v$&k#Q6;VF5@jdb#-4+)|n1k@tx*@y=t9y zWU##o%h}RH<$E=|7r8k|teI^X2jS*K)Zi3tgVgMov{tZ-ylGh_eA$|7g zcGn0738R~5bLr?c(+bHmR8;|ni}HHa^nOiJmAJqrEc>Kjn-oH+frTTq<(MiZ<16hM zvb^t(gA}*Ma{G+N^~1$RTnMZT^hIseA_C7BMQSp4Iz#jyAvfyLUTI^lF$oL_j`wBI zAka$nAdhAt(T(8|7d0VZs)0KZ54!ydKUzi@p9O|jB6YVwsFK#LH*&>&`#*xf8ZMcP zOux1VM@HemF-ZWB;6K@OwJ1jOqxqr5*duLUq&O9sm`=mhkoZ!>3PFtMP$7qW28HJn zs97I!Dw#w%Et(qy59Svf+RKX)(2Sj@yUEpdUbQ$|)LCW3o9sW^3B*LL{y zY>e1COmXchgz^F70JG@vG_>(20h6d#h1~M}+r-1*n>{Yqxe%ciljM-(=IO5${{1i8 z=Dyq?tn5MsB;Gls-d2qZh&(5#*Q{6#@5y^2=fwoE z>Wa3H?YQ9Xb(@CmBc(;jLT@=+jJ-uhT?f(kU^_>Zfi8#FCWeoLi{;FQWGm_v;YmPQ z*obINyiyK^Z@N5+9WJo$)dP@={}7UvFjeXu>9I`_gZIuwjI9%AfA#5o?YF}6yTQCx z@;tRAsF#M8!22zgD-7w2<4UcMhm4vUKj>lga51I@iRTkP*A}~w;7*7U6tb*}-RM1% zaNu|j5}D62MHE)-krcBSBFlA_9YGup8&}Gdc`Yxx47>^OKJ%{^Fff;^TwWlTyO}{P zng-#o?)qri%N*3Y`&G<&OCqpW*D6NHM1#y}Nz|*ezK1FBn8i)8?zJpLC=xu#V$ALO zl{fyBI_SE`h+%~LlN}@e<2fpp7qJUOC@AaJggn$lSg~;-0Fa0+dyw!_a@BekVy-)| zpaaY&7*H1kbUO~ENTPbzLz-&hazcvgE5Y3>adsq@8^&XiA-dOIP7}c#(W_41vm6ydMPfu#cZGm zAuea%Kj8Eg&j?`3rv+bL#YJT?3kfV24w04Ja1lT`c?Ol6sgY9e<76$*+p^vv>@(hS zoVGvT`(e)|_K{1+dG5p%?c;{C8Ex z?5!7ia|vrckU583YE*7%XAIWhJ86%}FR5yWb;(;K@aZx>8-ayNDJ zHQ@|>KJ;blVG%%4MD`UT9DrnUwlqJHVW1GbZgj++ul2KN!eJ7#7}iR>x|PVyqy;i< zCIJL4Dtx$xZsPukOb7~-3<;ZthJdL#WLSt=Zi$OP7I;c7@nJT~lsVmY z>|1#dZ;aEMI=pTRMNoUPpTS6ncfj#>%W`o>_I-%;^0{E_#Lo!`GCE;d-%Z8usGZFr z%w_?ngBD$n>gfhXYE!dBs8|;1=p6=h?@1x`#avR{&+Fz`~lJ&i{IPs>ROeYk8Z<@mB>@I-{Avsf}MR@;t{aadGICy;# z33OnPd8I(Uo!Yl=ytK!gffXrzL7hDr1j_Kt6mMV@ls^0|n`Z~R62pP{fyOOlFB?%3n-c)7M0eNT2C09y}Va%hT~ z+_bc9)J}rT9q-5-La%P({+is^LRK^0NfvQD*Dzm3-e%7S)_?i0f65QJ5uXUYK(t#; zr0;9r?t%ngya43N0?$D^Tmp=V@4y>uFO%4;Y{1h)|2l>;Nir)x>e-;C8zntIOIkqv zDBvKkLa|}1z&t+{7v2IKs5V>^hk&Ren;5zTBP`%!2gMP+C>QgA-N)bwp;H%mXR`2w zf;cw=kg4+cz%2bYFu4XlLfs_HF6@t2ksl%!oMA?_{B#tTA2q!q?UO(xuC0v@W4Zt) z`ro-r+qw9x99RHS(;sG^L~H^PKrbgtEYcl)Aa!|-7%6`?4Q3-F8I!`pcBnf-O9lwY zFeBpi^qNr9^MZMr=eM2m;wdzVvzf^R9U`odKgGtZX&5CIDuO*|b+9Pyf0GXWj7pmd z&1J)>?*zh7`xTOZ#7S)%w{_k98xIx#<()Nz;2N9IRvCqAo5CP{>ykqiyIZ4;n*z+G z6{e151hzM!f@iR5BG}MoUz??{>1U18XOO{nY{RMkGraK>V2q}B>zCDfI&@3YCL za|$f|M4Yn;CW1?`gPs_K?5Om|)TSH?6`UiGpas=omn4k`;%{()RG|t|v?PcuVv_KZ z>>I>(geu}9&)zt!hCM{4GTibm2w9DF&7Q2xO|s@IWAHkH_aRrBPvMP3_a|HtrWwc2 z>}C}9B)zWu#7cH|9!=?`o#$Z4nACH%0w{<%>M6l87y+ae%JA!Yy4*OxaHjq>%lhuy z>Uq#pPQS*3=?wJX^2k<%$#)1+t7+HZh*kg(9z`0pgp(EO>!bB6>YMcs+Zr`S!%;SF z9LUAm;m1A2n0iYtA?^rZPt-ObWir7gW$wu!fB5Z&4fZT5y?B_rp@ za4<>j^Z6=?IIUD95HEfUdDKp_=e;BuKdgCU3}!DfS`cauP_u1v=^0latn-4wJqjzr z(s&xWZb(VS@7T-F$f-I|Pain(Bv0gV8gzz~7d{N@^Xc*K7vy@&*Y?RZm*?fIgFE$T zND0u#y3@6|uedlD1XMCfFF7$PA>;N)ntL98uyNxruyJgE_`);q3#Up_lyby=!u{K5Z6z+@%$)R9d?0TD&X7PmGVWO$ zkk&NQtd4Zc0)}z*URd;P*~YM#!1e&oU!3myV4TB?5iG;R#(Fu-EnzDfO)=a7d4kRg zK~l2|aj=|4yj}+L-NT$94|>zXgLtjL9T~*!&Hj{*a+%O%0`zko6Wr~r+?vq4DZ^o; z&HM{cxphQcJVduIAvDQ%dwy}%L01g(`l0o#^~=C9OEUL+D`~kO(W9W5=*9{0j1%%l?&6gK!lQJgr2$IBt{t_Hq!~{+>7r54%Mv4ILyb%cJs>^`D>BT4q;6p#T98>4K-Basar~IR2JlR*g5z#w zQ({ZoCXUSNn4Kdc8UY(@R~R16f^#8&;%trpRGAKAtwLmvSPq2$S{%K-*SB<>btNdA zEvvg3G@X5Z@$5F+Sr`8b(*1zBp2a2mCdO)Kvr^`)xj?B>%D*##4+E(u3Eq3QuSKj%iTqcns7gJgwCr?C_ZRcP)o zmX>xSbAXMwK=xBF9kLsP6DcX~@X*E)Ijff*gZe8ye%IHL{%YTg3K=sAKoG%|{S?ch zR2-bxFe#>kcy0qxQ9Rl*l)$=-I*+qe#AV+-&kg`!oNpK~$)>)EMi%@4ominen`ySf zJJwugx_tn=E;W&>I~Ovtp5TJ`R8u-ed(iwgDK84_r6df1y_4(@ z0Cvpz!AlQ8TOV8ghVKLB=Z!0b*~Dok1IE52_Tk!nk?7W_3zu*Y)W!^<01|IC8|1RS zE2(vU!P&OKtUAm_AvBdZ%%B|)?-EDqZpEbmER^LhCmt*J3MkVLE_PpQ{)Ci=7;RNY z`OTK$JnGMzI9Wyj#W36ng!$|YGX&2v-K+S*A@ZxN<{1c^w^UwF6{+7+iLaCC?Ehccf`uUXzS; zIihIPS*1VW$*25|{-Z#2vzaWlBpC(mFJVe48!zXj)J;>^ZxY}&F)ijk@bs%uqKucb zbYC8a=NO8{k>nVxs_|7~=t0Mt`G5 z`Rq>$O4GzOX(eai+^1JHvupTSR|`^OsBYo^UX-E5D8bRKgQThR;R?-PPLy28lRGx! z3glj6QlRfb-;m=^bgEDP{LVnHr7%_Rb&v|<>V}kg6H*Go2!+3onksQf05kA^wL{z~ z@N`n7?&hr)pt<90lJu3{=KMwqzYi>hE#rNYO47)Crma=ueFV#wLy7iZz;SmfPY$Ry8PTyRW>|!0`ixVKKGg+LM)?N&o zneQr|s(A6m_49U;43F(>Z=>;}336lIcf+++DM*?wQz_t)qBK3*VLBQIc$~sFT(*NP z6}rted&0l`-2TjdDw0L$mN+(uE5l!c$4pMa^J?1~mx3ORJBh&^$HpYk%h8f&whGfP zaS)^N7y>}7IIxVMB9Hb+DGC;Y9VCyCm_o&ZUP-+wxO47r00F9L>bIX@^B?^GDQ-P; zIJ~^CGl+@wy`CvuD#;lcmLqoy%s4R- z<_mVepz`fzX&xx8W6OJ*ihWmwKJ*_c+FD65e#%Z6ZDsc)YBR7-|8|B3 zE1=YIpggBjUQI?`Zcb~WvqbaeiiN7tii-bp`vN?_bn39(Wacnfk@<4~@;*!UTBsy# zf&n*9hm0s(O2W}DGUM|#OC}xGIryK=n6G;Idr>7k}H@|`YIR)n>8}=Af^K5rRp{ zF)VxDigNg`#oztC!~cBqp(2L8Lbrt_IY;U?vGH;tX{8RT#>;wYv9H%r@85@+!o@_b zhb(G>vMSwu#?(LGV>XSoayIyqDxXBzCks2_ho;a+9UZ5#8fW{uZN}tOzaII6P{x*8 zp%*t3jBL&STLN9p4NMhI&bGV$U_s~pr6GF%aK!!@_;W0E~Y(U~XYGxIHAtaX^` z+I-JKZl_odYg{Q64yQ0!*N*Yo-OqB*HjXy!5C1Lqafmd2_!WXv3i2S}Lm8+JA5?z} z03jHs2=z}+H_$`7S9 zga4?)rC;@rE zZrk)KF_$`k`5i2mCc#w##bMPtXwslE#Q)@b8ma35WmeJbk`Z!M2E0PNV^(Yuv!D(u zR6JLR@oSSJkIb|8L@Ca1+*h+RZePi=_Z;y)=+sxw@$^~>gaGEg=J411x}%M$k-q-u zdi4blDC}qfB3`FyL733yg&x#XZ`V;FU#~`qQro??#TPkwAjFpZ!nWE8nw&50_6M zLs6HJ9`)=(D|a;2@3q(2|NW?0KMi&=5gp-BT=)KtTSjvvz^q86KqfxGWUIjIsE|_ zuN72rMhLKaG7CF*p4}IfMjh_H0<-isRK#pIf(&{}6w7|8@+9qTIiQYjnr+*l_?|NTj=}*bl4gdpiE?cSb&B!nHEwA_0feY)}55sIG(Ha)aQayi^`O965rU10jY%ximB@W zcjTiOm-!3!O)RpZ|IE{RQpu0JlxLq5f{UK-$x-{Hyan8b8pVEvyUMK5cwaV7fj@)? zM|xV!8i~ygjCk!hdAz|r!VC+qENw`t{~<~Q#G_{z4rBY0s|tcVnZUB$!>1yfbB^oO zJdDvCWS@K6$*nneO}rqLABk*^Iz~Tueo9o(oBzeJC7;GcEa1l0xZ=KnK|}!o0oKZ% zc4DI{Iv_25(WzGnjr$5c4hF5*3f{LTpxu+xNw{)v%oTnBJU8?IYKwi;ai>~f z0129WUdockw2B9DUK1B(#u9(ev9z8)pt9R*DRfQ4NAVY3J|=-h)sWn%0j2S$yOCXr z@sJMLFiL#~Qe!C_EB~489uXfFT!CH6{{@s34j^Aws^bb<&kAal;T#h1*oDW4hhOkr zG4Xq~s15tJ$zlN;EEZ8~rd5e6k66V7DZ9;Lw|Ewbe~9rn;@;bBceAMibL3vqk)8Z^48M&y38$T%zd{Q5U|tx<^?8;-;EKzKLATpmIjG) z$_@H;PsUH__my0^Q8Evw2L+CK{!xoMLxe%}9E~}eT}DNY)`hW{dmm%GBmGh$)6Al-X00w>bBB`+Q&F30JP8pt&=(}TArOIFwA-#j z$m$)T)rabcecK=|7gZB?Onbo2V`-kvjqas-vcZI|^Cx@Zf!T$PeU%MjS#LIbx#bA9 z3Z#H=lrG#ZOlxnnEY8m$0~-&xcSLqaGsWTJReg$KnHqTn1f5Wz-|W$5e|z~UagDqVOBlByF$S7gvg|2msMxP5 zSE@YnIDAhnj_M<+=k~dtwwR4!cSpp1m8j9p-BvYL%LCQh>@Uyho#)oBOGmI|j>l~; z=+y5wI(J%(4}vl!y+V?ne!MMLzi)Gvn7v2X3ACxA_YBb=MGIM(0V2@+%hm|B_oh11 zqGTuN=#x#~*VCuXP#<5^SfdP93@gQ{4gNvkycCZyU-Qk`-{4H{amXIr3hDF)Kq}A` zVXCq9Xl1%nr-1br-8y3Sk{l9uuhx+t`~o6|_^x!ZY=k+(BmfYK6H+M=Mh42%xY{*h z*mo)WNs<{dF+TxLuCjM1`sk7}B4q_5*Dio8F6dR!#%bM^1L_JXN z{)*TB5>Z=Gbz+$;t*<9hKzrPiO(~f2(UZ%HJju>TK0Gs&JOPB>F|vh02K+Rz0J;&4 zpDhu#n7raqGlckKgKY&Du8g?04WWtWW8i85=76d4KhP95#FE*ytb?^_o^v%uTDevfu2%&7Wm279Z72Ux)krvgQ0!>)LZ zi1?BbjLA79?(Nk)HauGU>nVDm?<6d=eu(uCU|QwVz2^L|lS>6;fjY5LXiDaJ!dlA~ ze5+M5Ym@f_HM|f1qI<_85EC-3d0)TbgIgB0?JALRXF0{2ZMqPN<(Cu<@M*$J5Epby zIieq_C+2=&zXg4XlJ7Jc`NrSd$Tq64>~Yw7c9g38iN>C9a}6>MxHs#`eAq37nazYq zVMhU3EJ^E*#x$&h>De*fL!rcYTs;oj%|cDIdpfthq7L@?G+m>`0Y zD01X_1E0G;u6%u*o-Vj`rEJbG;b2P0_$vsAJa!wT66z7~_-$86-C|~;mhaAl`XmZS`8_`Bnd#YGUkp;KHT)V zcJ}`$kI498_*lmFu$@xKbbRq5m~8Ua7UPC?gGi1shoR??$|8J6jwNBXYQ^vHpRrUp z8dObki^4ZZyURO%Np8$#mX!=w0Oz@O1SuT6qE)`59TMLwKxDb|2%~(Zp#0;DHVzRW zgQILa2q7XQZr(-i%O?6TJ{ESZ|yVjq5%;lbh)P(dS!yOO5_b$CsD` z0q7UaQ_Og*G{TO-VFR3r0s__lcDab8JrvxxPgHcwRB6|d82;sjUxpp(3lX-AUbxUY zF^zMnJ5HR*MHavjwRaOQ3bI|JZ zd;`VuSJ^~xp7y}2oJ9O|?`&#IIDVL3fP}Ca2QvH{ud#W%a);k<#B=ZQNKD#(ISPkOE*CDZOE^h3~Lx7#j zhz_haA#5ZHKW{S(7j6U2k~SoZGWNYD8MZG6-YvQrJwx<(;Agq2V-MhG%VhfJ&8xGH z4ukd$3W`?upp#{;yJv8LwPF@>T4HDu4@wSkVlKtoF4DM-USWK0g=#gnR-G6(wQ$2?ntGX+=p=Bd-In;F>l3E&7bi!O$KxAinLkKzT! zL6OFOE|{(I&`TCwog8vLr^+aJDM4BmqI zOR(G{!B1qpI;bCwb&>vFd;M(UY6Dq85M4N&#dGLYJ>w(Uv{)m_XGY4#^qF7+g9IUf zfdEcMr%BeP_RsM-u5xFd=G$$3Po%wpPcwF-^0!LYFW5YNa-d>;p-nrF)4govZ1VDo0{WPAta`-pg^!3HRQuP~q#C7y8wIx6Kn_;AZ=Qrrx>AQ9XWc-aq}MD^cs`s}Bh(Y$9X5TYNSJIx)$_p%S0( z0(^8$6keVO7O}ct84kMP*>b1BucmPd|!F5~-W46{z{5c$i-GF(UVZUt&ne zlss^v9&P}SVthl}?#Yb_X4i_W--}qf=Iolj2>m<=A}2mV#6K|a1sH!xS;xWu$1`#7 z>dRV@D5ylYGKb)x;rm@Z4Hh2+o(lujmIHX^O1VR@(!cc_lwOyETY?`>aj1)l+pC^$ zGji5;^fj4%&4Lli8u^il6Sr9RkMEKd(&=*&c!r~zIIve^27Ihb%(uzuPL@j${tzdk z)1r1e#U%~ymvxd15KE?+UaW*wvUE}+eD|a4Wqf7R{~bIA_`~WV=}f^NHvQWWXH~S0 zdl;>#!(!;h(j@!I&{G|t9c&+(I6B_TG zcl0A2UC3H^;xlbQvtPdkzjpGd9E0xf^4VYRvgm#m zeWbpA&Hz?)YYt>qfp)P^d;78JPQBBQ@OVZ}dj>M5fkN-23~l`dZ?jP#iG;>$k_{K2eKDIlt!iR1wHHu;msUrar3g^A(Dj@W#vXo=7 zmik51=$NITYe6gFGD~7ICn0~_gB)0dHR6^vWdYmLMK(rZY0KAnsSg$*9LHtrHJ%t&nHtW?ero7NJRfD}}D`Y6dK z&Qy7R>8!Kt-oZ=*!AUV#_Vsi+uE?J`cT;62qe0JL&L~FgfvktF8=d|uahCmSypKBd zwF0AwjgmOlY_?~0oY^1H{SV;2AYiETDgz^d*Cx?IZow+S~R3>Bc67k@A?KEJt%?~E*xjtr_ zyk5Fh@&F(%qjR^(QJ>T{9KSk!Bfvx{Sb)qGo#k3TS)FEmqfAPk-Ijy_knd@{f%d$k zv%ZwQ3?-_3Fx&0YruW6Qji`LmPB*Y~IR_xZ5qT_5JNggF`r5RW5bl}ZwEf37?IVj5h6ue4bgVW=9VqIWWrxnoo; zLda^DnjD?zHX0k{Zmz7Ru+;edtK4*WB_pvUmjBQx9Tmq~+^FID!NEc_g7#Ai^!$MN z*eUNY)pGxpyyhylh43YkrQ?CY=ej-Hm&JJPjg|*wAag>Rj;9QHIL0Jgmo-bf<31#q zm^FCQqUns%`EfCTufL@JLXitbObSz}Gz=%9AR=-^_RotI^0wG97;|s|)=PcxL&rXv>?n}HbD;oqj4i)}Hu>q-HvdiWUdryN z04%GF|8e>0+3=wPNK=O44b6N!LtD?z7R;621MgVS>l0ZX1PZ?A`;s1a z{N-?{SB67A84mdw4#j^z9P%FxqSQnBjL^st7=aE09|${!r^)+)w@mw)*apu1-4&3F z(E|V}|3q~^81~Tn1k9uAyz#$+Cmlc>0UdFa!jM)BU##zauXC2~#&BYA$`=T~k`_tO zUgDu}B+qqPs0-R(igZb@I#JTy|4C+e$1Vy?DKvEB{lM>lVhX?P&Q`=91()-v%Yd%| zxDwUY3(JEztRZNt298KA((NGrAA*|2V2MVC>KSi)5d4Ob`BPvRwep9QrIT!x3&p)H zX7>n=dn8Kvjb}GNdcfEesCCeWU+>=+h#v^;_@<3KsH+RDz!1&YJ~8$a6-QwBb7OXS zZQ3l>H4|BIf~O6U`j?b!X*Z-GbBkUyraSU}1t|1I%x)4r-(uH|TXrxbc$rE-!|2q# zu}B3qn|x3Dv?(Yx8SvwyVD`5->fqnVl<*e&=R9pP#U3D!4EkSX=$EJ{8T(#LYN=Q+1J7hgpNt!i?l#NzT& z{BNQU!daI)!NB%3Logh!pV?Z?&|@k=fZx^kV_^O_|6LWCMdL zy{@=x7@l;v63AVPHD+GhHkXlje}0rUo|(l4$e zLx6zlA>-JwB3&cmIpTLSE=Oub^|L(mDOuVy)2z zBC&lnu9#w~q^!3J`-3?(4(no#)S}f%ne|J-Xhh%D*mF73e@B$#KiU?e`=Vu7Q%b(7 zvHHS5t+_B#?`b64yuVZ~vdl_~7(wSrsdc|t-v|xn7K59AW~_$uBH7Vz4G8dEJzzhf$5hsIo8i9cHb>L(dsQgjK?j3CAy`N(!K9yOH121@_@{DJ|z$n z-wE@T?<%EmnQlV9V&EA~V6>CjgV<7pi7;2(DHfy5Gu~J3Gij(ZdtViy9clkV%nmkMZ39#5K`T_rpYs~8W< zNq-mt2=)N0M5Mp~?ZsV#e=jPg=*#ioyArSWcRKf)edkruEi7gDRypf0^fzgR+Gz&j z4|vykxsEF`o6BrZ)TT;Ki^6Bx14k_f;ESwxU$YpOdCA$7JCh+r zMjNkR7|1O)nL=Uri|Ae~Jrwe$3NFeG zz&>@T;7Hi`5!->#H<`A=X`YUh;k)6W6j%0YR>37WwVXbsXckj0ZF4}i#`uHW9?;_nBaf^0~|K_uw8$Y=)B=vmcYGrEku7C+V{66 z0gu2`Cn&mz6iYBguug~OP9=@cgs{lO;d~olgTw-YYtCnPP^X?$Dl#L??z1_rgciLK z!r2qaXf@}%+zN1$u>G}a(%SXiq%isvnkfd1cg2B9_FA*A*YX2w3a`+cn{`6x2sRL= zts40;HJoHB$IEB%bBz7ge8g$&don{KR*T9MJn5{1|9KN7_{N+(+8(87@lB zKx5~V2temL=TFGkggq*{uZkN(C@19Wn^&M&S{a1JQ52eWZ}IB% zllP_9jN6Sj-$s6vor|FhzQm+csjw3Nvi1ObwJ&cw#UV{GI77iQZ`A&d6xL4@}H*O&DbBkz&ynP%p zyO{aX^y%vZ+%Sd-0Z~}$DIBrIwOlF zP`Hpx!G&DBRtnfZ5FUA; zX#ACXWDQi{*{+Uzj+KxDOA|2Bn27qBZDuBP6C#B2y-ct|XDm5Y32mMtAX%p6EhG z7=RZWqy&tGKFK}{?(JJ7^9&L`q9!nn3E=(G0MZ3oQYDbXJ5vHqi2~+Y(E@PN(Hb|y zg;bKx&#u^yOMRw_HGnb%_i;y|)wMmZ@mvT83l*rxLdnKBi&1;LKVI$%(K68kX zEMr`uL!EH|e)!twSN;*9C`|KewO3dE)aGv!7rvTe-`8=u=n#{=ZUa@5G?YU(HwN84 zSMh$&GVmEoVQt`n;!X^fg(*A4RNH>M2XQJLg%l!!vj~?QYh(JpL`=y?9NWwQ%;F;n zN(yzaiXZA9(bx4QBVBLjc8aFNk)1rbr%+Q*#slqTN(BxI*GE^;CYa}x1 zQpR12_yhA37P|6Fl37aNJj1tHUD*Wm`_isc`Ve?RMz(Sohbfgz(|!(mH%;s~eH9fZ z;!Sy&xo6;zDb3ZGDN<%Co$jNTaMhz*Xs$85c&M`C?V&u?i1TL*|%@GXH zSRIu-OqIQvpx*Xc#)3ND3#LS#GRT%ZD9Dr;P+gSi+^MjxyM-8^Mio7lQ{?-jjffL<;&7k?OHk&?rZP$f zn&I`I?>)g30r<8`F3TP+Ca;u-lSI3hx66(m%OAKCYUUCqGpC~>pretIAxI|+oT(^A zdUR>oqX7i0_a-oeV*Zqf=9Fm;10xnA1|is(iHS{+qH8P4GKWbtLLp{oN7!!%C^l6iZ~?)wxmJwupoVY_-Foloul zSyFc@Y%7rl2xl9$N3^gr6o;H{k1t}p%&rJo!29p1`bPclEI!>`RFCdkd?6$pOeIEI=PWtD;k62 zLd4^z%e+CH+Zn%a5HV;hT9sJIKvF9&Tv+|aobo$q6s2^CSlw2SHNQ2c=#rgL!*YR2 z?Zh7Kd?)-y;A!@T^xd@R%?Jbg%PfU!l3un>BsbJnZ+{)03zJ>c4ZUi@O_j!dBE<$d z)UQZ9=P@0UPX-V+A7Y6ovQv59giZiw0$BSM(3|!5A&RIiFMLyA>kD5lP~&exjW=;& zl8-^15YwR1qPiHqbiqlH24rz@#x{x;y51SK zPY}G6JS~?-q^SDS9W&S9xqA<_#*!`Awd}dF_w~zeu&CP*o6HC>OnX48EdcaKseGgZonH!-HOICYxEB zj8QqlC|7xuWq(tll$dYX2Bl8Jl~BHn+Hz_4`)8Q^E$Qye0E)%hLC`+ha0Rb;KDDc{BKw(D%Y9F*TnwgwWZz9ab0+LBv0PtRJOJzLI1TfNF~Wr zrEHjshb;;utWU*-jRREV`tV>l%1~U$kt;OBN|BI@PJ>fXT?q_ccnmOt+9(ergbS;B zcDdvZ2I*q0BZf+f9l~Oe>xsGggy_Y?xmb#Kum){*4+EF=Y+|nn|AWZqQBH;|@(<7L zz@kt*mvEj7_^?*~6E}?++qwo2I_-q4p>hIt3U9wzGaCSjjPLtrVJ<8cNiK<<5_trT7GWB z8SO*P68DwGAdjHhVl;iH#PZyYH#S7TIsBRdHWE zD~1nM#h6^4aGJ7v$AdFO`Y&7RNz-J89Mn~7)~i*c%IHt4@7O~HKRPXCj^&ez0^Ej1 zC3wTf7B|9Y7TQK&3Rm*RjqZ^ZQEWt1N`VhG%Xf=~(gO#IF^9`TM&Bf~rHFlxXZ~EJ zyGxdvuN{?FIs^Rz4ve@+*_D-kNTLH8wBH3*OVri_X^6ntQ~(U4KMpo~s^aclAK!Ib zhHm5ZjYOwae?E%b)Yv)2c;a+ll0+qj`FJpzslD!f*bap%1?V_2<-UWNM4h!tAS4_e zO*Q5F%CbOw|HRmi9jqdzenml=y;*-azAM_>iYvHjUusYVm=Z0iy2Y2!{17>vl@L?! z%{Xwks^;GHG86j=0D#&s7^7Tf^$CjdCIE?CpURUC^# zMJ>L2k#&hif27EnFrFm>qq4U`!`b}HPW^i2Q|v8Ym5G&7tA1%QT5flK5IKi~1Jr(? z-a*g&Epox+d%*ald??QK^-jIv(Fp*nh1LkSm$B{e5?={hHH$NF4sg)Ma_YOH@|{qx zK_I2rV0Yh5r2AP{3Il#(xnZV^gh@%#d`VbdeR;z11Q0^(?ihJZvxp4>-gC(`cJ`4{ z3IH$0*iPQ}9&q1gxn3Ljkh-5WdNj1baQK9$IcZD#*uE~?7N19=?m_ss??WbJ zU$UGG(ab-{pF8;cCB{RXhC_>N4BHg7IM@9gz6lsIQ=ovXVQL5OH*7v+4ou>chi>hzO@$hqH!6I>b%#YR7ih)sn zBeZ{+ZQwB|td!nbOU=6B6|OUnmmy!oaL=$I23T5p=F#&6 zpbD(wk{DJY+}A}(bPeOOR0*e5LO6K!c0YWW>oz0BG)Os;-K?@_%%-<4S=_hG{_4u^ z3i}Mjgb^gQ6~|p593>r;k=?SsyTL*er^xCk2bex&_LeCUOhEI-L@>$FZ8+}JD3;H; zXA>>el~nz@>G;+R>yy1uTLOYPxys%D^P5AN@2Od}fGIU*TdBy<;5Rp@fdOZ!ykY&;F6wB|bUyQ{CPzreiP(+IAHRv5%VTp!V{v zRxB9A1Kb*ciPnVUnfLIrCt0*Kr&`{Y#P+7?ma zZESE)v2AX-u5t%pq8%f7tXo>CJtIg`M-|zSYV;0{VE=GQv=uj6mUl}?c&M&Nh?Fw9 z|HJmfm%2;QfU;ttX%tllW~#-$8+A;FbA*2+c$3!|Z@kXJ3O(Zx*n0}I*qynJXC1t*UX!06q3U`a{DkL7jt769o}o7tT1G ziqj6C5a%B{mqIr6(fc*R$_ugZE0)HIR{Iq6INBAwCBe$Y`*FEG7aB?)1SL6_I?AD$ z52_$#I75M%1^b#fZB#37#)Qb6YGMiIKvZI+8G?J_OPE%4YY)Bap0FBMjU>;-u*som z+jRrTR3>q7?Zg8eN3}%Wk9&x`j^PDEN&KMvT6cQKl`!}6eCn~zA!)~ag=2~%cds3e z(hNAzm~Q;eroUcP1GwALG!mqWH0~&h;!vhOriXjl_jMvHlVXd2^Ww%J&!bKQGnf*G zag~ulOqIYpf=jO0F(U^fIYy_u`*I%RHP2j5M2mnyBVWC{jg9@eVjm;0q38Pw0}<<{ zY@~nNJ9R5_rojR199K?iU=Q;rptfkt=|mzqg5Eh?x}rR88g`CBUDq5&eVpfBNFWzX zR8bM%Lf1bUP|=E|HN}O(J~Q-h<5y=47qV7&wZ(r~DMw7j{$58NIJ$(_##D*B%_ijW z&fnYhinAMbh$K+F4F~mwHG}%XH^pH3q zhBwjG%NhU}Z~EN*&1~HCuSIQ5OamaTP+P)%&)+Z+6l+G>*&5g85a1I8oJ`|R?qEy> zMMw_5I7+1kC_G$%FKMvNr?nVzM&My*3TkK*@lqW&O^l+@v#&5OU%6D`kS8OYp@& zy$g$gSsO~FF=Cc~(_#!08^;^AABLran1qfIsTI#itIV#ktv^H`6rbao2SThJ?9?AKOeDs8a8fK}K_SyA z$jJue?`fSyqEGf{Hm#2?W&eM*oqJT&XPV~=kfz0L=sqs7ryHSr&rZ81bOd8;E#=~L zHpktg9qnlam8;1ZJKoS#5YdtXT*otb0<+s|KqO*1C()Lhmza`kfrMcv6jTzT#E2G3 zQ>7x8vP38-s`m4|rAg16-9NieYe1-~-|zQ6@AE#F@ALhJtV!7?R1BR=ee0cA6Icok zwMPd(_+Qe)7Bh`O17L)WIxl5al3xIQk6>S2PFG=UdcDa#U-HWkU=`ukV>o4Llr%2C1Qhjqvi zzDpl}G$1c5`u_+xum;q-2wmp^J4z6isGEg2 ziz?a#yv3euo;Fsr%rkkToLvAH^-e_FtS}&U$q+pa)Wo99YKpy(y*A&KGl2^wlDbnr z0BJ69ff9x2BvOkL46*_!5P32S=+LHQ>nRzB#^+L&x_B+P$dV)=E}Yl<{$aVUX@$Xq z=AhTslr{t-lO!y{lwn@$g@{gcG)P6> zFUoX;6A;3LBX%17`G{Zat8! zM4WJD2oSy(Q>6(i;QB4yTb)|G5G5k@J4uZ{tAz9!L;5GGJ{OrxCDMoNMi`Z;w8sb0 z8fW%@lLG)6lqDR*WC4Jy1ywK5J>I{j^b&JNGTgxHggSj8KxW=nxPaLlu1d(x$CnWY zufcscup&LIx+UkgV$CwaBN6BTb5=wn951mqWpPb z^Q&mcA=5$pq7_yrwon>fQ3^rne>bSTCikThs?JYHqY29!9Us^mf0pUKM@lY@3GAkL6e2wtV^1EvAw4nJcvPEOCOCBpH3qv%XlQ)g zw##KIltt)krh}pL#F%mArs+dKeF)Lm!384t&IeE!M(e>{Ju`@s@r`G z`Hy_yr9$mLw{hZ(VUcmfvjo7nwBX&d8^@Uj5ZsnoV1iK6l2)0#4N8Orp9KPl7)8Ug zZ`EK{>*^k!Oql*d9dpJ%XsM=OCqxlr_vTT#5ySjJi$hy7^N6ZXrUV>^X^f(=Q|m1i z$q5_#kD;vGsc8dciQ%O-mrAl!{BqPZbJF$awcdXq-bgFOtd!)_dWOEPqDsKDS6mQX z_X9ZZz3@`Xy~G-H@C9%D&DgyjbnMuuv6RlFVfK)0U0wt8J01R1$4W6}D$C)J`l!TP z2L~fbqpqm!B|C0ZlNg1s2lS7T2Az#!CsF%xVAQ>Ag6?3!ZP5Bs<7LABgJr8Rk(H@3 z;Um4LqbzbdqvLcyEH5&DPcSRE67us2m=1yKFzhZaM~M8v0?`$@ldr;yDe!jl+VQ>t zc!wVRP_9I+uvTrdNmF$$+0VRx;G|i1M}~@5GUtR{{Im91crvoIA|Q&$UIV6a|88N( zRhQmabES$zk9+}v4UQhT%{8`;Vt8U_Y(&z4TA&iW*J~=ukgy(x_e7d9DW+rF<`be;5e6qYVe)^{2Mj@#`sZT??Nm0;;84(Lz538 ztq@e_2DS+F;I`{Bgb876Ds$=~@&Kp)Z z6~}Psq4Ay@8U-e|ct#?Hy9$?)V!S+u1W!$P%gi^hr3ex2vAS*t>_&%Iv^vv(U ziKaqsj+OcHHz`G4MHJE;#dFN(1aFK8A_RlRP7YBJe5Y<^btFNSj9!~V+c8vr3xaYS zJncJ3p_~WGuc1&2{DrdiVtXzcI8rMJV!&0jSr;Fze=Uunvn~5h^bVaKx%#PdNiDln zOKq=rk)3oKFE7D1fElC=#i^q_>G&nU1lnpufw12fpox&nPht(u&*l(fN(T!fS^9*# zg}a{-w3RYNOLWi5msvd!9cADb0p$eWn%6~Y77F=JF=-d@nOWnw*=q3 zJ-Y-T3d87>NToXFxY`A;Dh=3;)yVO}rNfKoy#bOmabnEPnCg7=m#&R0?EAQYe&SIi z0abJ3{hIwDL{36A(3crc%EE-CDtTG{p2ju!P0U^xbI4N7NmU_50X#V9GIOWegmlXb zAj?wsusWTX_EBZzw#$$f1jJO-UfD^B<~`>((sbJ~^Q0s)$o5qdMG5!#{099KlxoR3 zf${nH*2PZ^7wd+i63U_y`$j4KG^TZI zuMb<+jYvlZh2+h{gr~o%Wu1d3hoTMJ1l+@MQk2;m*Pz=MY6Ea0(?TclL&EfyAE6IF zHP(evy{mlaBW=S()#RUn;Skl%M=vKUxg$SS;rvjeBCKdt1PIroXEHRN$)baBMFj(3 zGHakcTu2T8Ga!YSZ|AUslLOUVu&xp5>Z*J)JAFj0;}Tk9sn zxMOM#`^BWq>e+$IXRck)`GOV`nR|SqiuWP9NjEd>yv>E(!l;a@LlRY|IGi3CNK;np*EP6fb* z0E~JpOUIVv-y}0gTEB1wRMHELZ|PCJXH24P7PoGv?*Mjv4N&-BRkQ)bIaOVgm)gJF zm*j#!MO_4p0efl4rXy$2unTG5(TCuOf}K^M2~0gO#40wrk92pZ01;M35!Jm;D)2tj zoEJL5BKmg?NldZn0f?WE81MBNG3I@mP3~k|Z({7;s)RC9-8PbhBZ4ItoTD%t&t}e; zHvQ)UL1tnN!1{7~;0X{fDi$Y)E$A}Z_7vHNWV~dy!P{Gvcd&c-ty^uy^jfy^%dpp; zg>!LThq#64>mblMSO^zC$?$;7b-YvT^7|Wqvf9DU@h${oF z6hET7g0I-GFVH~ZH2SE|GdaKjL~m+`q#=?E1tvG_8Owk}iK7!F*fL{;JBCO0X%qxA z6Uo&@x?4nFyOy+4cGL1X06iI-X9?{fQ;4uGA%2n7ks|^L&-^>pcn-_aN3m+ai9*cf zvH=Tc}PWQdb{WYqrU`9Ei!$tK1AH|t}Z&9Z6DMgCO=8S8Y_Tc zsR^70YT1#wEU8zL+jW+Ol%uS8rM`k%ZN_^Qw{ehXs@6={$GFBC8{mvoaP5UUtmds8j2fdSm)B+ z!h<*!iZeocL*55eNGgJ?Gs=H`nWB|N-G_UGL3#)};3#O&t%JF;_)Ft{jB*9kZW)}N zFoqMS5qEJ>2-;JH?BRu+c%X9fD8o2bLhy9{?!|ilpV`794&7pAdONAf#8{t{yU zCUh8)2Ldp{wXrqvmzwi)gjjF@{5o<#78G(z83QGjbxI=$S1M=`%S-lr($~@?3UHTL zv#bs%-#I2EMh%FYdZ}bMVvi8E6C<-lYF2b_tvoDor^AIDySxF6Tcmm~gA(X4?hheU z)`%$JQD7D{7Z_e8Q*qY&XQbr||7OaQ{(&eBUTa&3gzjC07;Q|yP0;-L7W42#wMu|`9Fsa+odO&OB(aJj+tCRI(*7QG5YVVyESHxx#W ztHM6cX3S8EIBu~0i5k{|3WGaveSA+BRS$UJ+4BqXGcJGzO-bICiZBBJwNttLy%+guTvWYv&tj2|0ytW-;2Mj*v$j~Po=z4~6_AOvk zN8f!ZyrT9rYNdjYV=QMGKGpJ;7$4nX^c?m#e`_~_+&&(K7IBdiAxn&DNe~S)bx}F* zy)0gM;^5eQ)GVS-4HA&@J&#=9p2gLTX!|b)!6wSi5tj*jSFKJH(a1x^tD7*m4Q!hb z*cTe5BF!uxP)PFUPyVS>jBlnW>B{b7orzHkZ>Cmj_KyLR`P}h>$Wr{Ob_^#<^{PfA z&8STT3wtGbKZ!%9Cvdt3L^4p80=el#s^BFQ|LD(my$_HpqdlDct&8AHqCuB-1(qHe zy#=Is`q8h^KeWGciJ6fU>bOGZV}VHqcAwWb`^BKReuHzh1(s2NnOimIc&G?Pl=go} zMj-SF9s-VNwebf-}4Q6zarqz%fB}G@%q?7KfITvM`KG!w>}x4RMJg^rM~F zLCjr6aG+H|2+PG2Wbz*Zmt~e+K%us0(|4ABMq~FDQRYQSY$qF8Se0SMaF(+OC}S)? zDytH1Bl`ig#>hL&C#{-wNwJ;Rd2SG=X7xfq&Iv22l8nz6hJ6J|R1Fk=B>G&y&a79) z)TTlklZz?R@EyQzv; zD6Cj1BCxnz;oKzl530 z={ci6hzm!bNS&y5t_+hZcE*ww_!-eQeZ)@a(c%bt<4GYCfpwz;>G zZGRGBJITL%Ck-JhL2#GMX@F8d8IU*5FRVSICj+oXRMoH+b4r-bsrI zIR`xu7~FyjnlL(6UqJ5xO;zuF>()R3yet#vUmO)pO61?hlnh{q)67)pY6-`JB998u zP~T`PYkY5Lzef=NR2=`zy32x*TkXXYGiFJnUs=q;hTU_p?rvS_Vvrc+_6>G{w}r+x zi&r8dAn`)ym&PzJgZJ@^Uj9M(e4IEHUCOuQF)kyPeItcJDbx`G>f~v3*gtm4VC#&Q7sr*N!+<`p2Q7l-BC#AL)iqJ zC_+rBL3h~V#}tKYA=J&E!5a`L0c*~y(*Eekc~=q!Vo-V3^_W*ZNYJ73FnlN8a@z)C z(dFz8_B%qCr-fRTSr_orj$Z>Qge#hplqTQaw6noZEm&E|Xf8lTN%tQ>5LfI1>M(e5 zcErrRRg3M~u6!2*pTajq1scwNOcPkGw6>*72rU#jHW;6s^aoGrv}>@P`hkX& zU$utse|#UXMO{%)8GvP}!_sLH?r1@vFcn)Kw7FV3O0l2A;tW>=b69s^)s9`QSX0Q9 z#sywTgYCGuFv!y_wks=8RH~$u;Ces9bDKV*> z7FMcb*lY;UDly70m(JhD6Nysj4eQ~xa9e|3Y(!IpF{=gp$IuX3T8s4vL&2#*%?hY|`7F@I+Xk`v zgB-6ossTY&0L>!y#sG9@X(Q11N@M-9KsHHLUn~BV@Q5iR7}Xfa5mfXR&{wF@Ag;PW z#59<5vC}i5uTl&)hmlrV7aF1tvD_^8YNmzr;b$tpxiy5fD%VEO!1&|5#I!qrFpvp$ z0m&i3H2?@m#5yy|phRF}(ZXgru@DHfHVL*6i3Hn_m8#Xz9>&eBOQKJ{TsA?jG^Leu zRmjAxb7QAK!JwtfsV?;riAZHkk`Xr0Nmq74O_->zM9RK&&?pxmR3!!n9V(cnp2$j*XSf?Iz>a2 zI{p<3VENM5#nE0bPs)|pW7ED+LC;B?d^(3uacG6i=BDGoe0Bm&G@Y1fT^v+BM3;xD zxaQNrA(CGvz|gn#x%07YE;g{`FbpFr#c95ZOUM1Qx7hpjG&ex zZ(Yu)8y;8jWD^F1{C^ECJOMs3nZuAnFd#T;Q8&xAU|)suW0TC2#8+T^XC#Ux)o6<} z`^-_bwmk z`5a2?_Go+FvFk=2_X->) z0GSFJsbw#xQtse(aw0m04&Qjs^OL-wihmq{ilR@3EatRiow@VH_!p8Dtn3)ddiW}> zWRMre@eIsYbv_6e+LAABFM&(@zrwlax3P1 znSsXUpmZ$TJ=l6?%jM-vfb>E`#BURoNJPrPQt#p;Ct5!C&&V!weiI73?zi?B>fFyg z-0hdrp(`V7sI&dWdluR>_cTV zQR)vLk(>}7VQ-c9BiNm}^Vd^)O6w?0E#0>z#CeU!%*Rn{>k~MDE%qm+c+>;sVaBWI zmUkFy2E>#T`##eHdjWkEB+vt?NbgJH5wWEra8#d{Yb%*;3DjgU(ex^(K`AJ^2U!H< zO-1rl8u~ul8_}g(krrtldED{vtteg5V$P`D@Wp){h~qA`U{=*NJ<$7iq2n~g%u@?{$@tf1-HI&1yc zbYpinjYC3!aE#dxM&O;5_Y*S$!%AruI99#PWRUzJct3Z$t(><0lKI=x>@j|XQ>@;Z z1*Ej*O^ZlQ>*m)+|9~T;HLKfL! zkvz(pn+J9!!VHiVRgxOSV%nedR~z5R9`F7Al4|pYq|~fy8LrCjI zVv+_C33N^%g1D5w*{W1VgXI7XdO%L_UI4V3kk!gXCCyAT%BEVP;BOdj;TV_W7i~ta zMkE{^dr-c zj;jx^^t>$bJy)ByCD6P8@}3L8+(XTvvBtI4uX|KFb7dWg++LKX&7;%EHWp22XmY%;2-U;Z3k!-}EgaeX3d{9d` za}VYum`wb1!o2mD52`^rhD;BFW|F7#ku)u4Q*mP+fYCT&F5D&BQ}jYhkz(cYur{DH zXWf}EKf5foQo$cl4UlWkyB2ICpQef57LI6b87s%Jn|guSF=|2jf(`HC|AU3oPJs9= z;o|dU9F7i-vKZliR(RV*vYcQZ1!;9P7+)syXa;@H%-~VQv#nFRIz^F_Od(7TjKxdO^8XFlEXJqvXRBSqK%imHv#r(v{UfQd%$2?@qpd>12IDW=moEdOn1AN2 ze-lN}vb{izH9xEh`6Jqd@(_t-WtlU$H5jG6-hob(8NRyBel|;%L}+>&p>KAF zb8m`VSGIChMxfYxOwjYlRfq=+t_1lk)f*}KJRkkPp6*y6GErwqiYvvzupG~<0^|%D zD1p*-=e8tS4^9*lV)j>UejvI&8#(jATewpEso@<(>8!jBV!`|-AF)|*-(2gPpryZ( zi`3(<0K!1*Oji~6%sOH5%CUe378os%cd2of4CI zXs+J5BfUf7o~H{B3D@bXOwQ&3E~?wP!QO)R;K8x=*f;8}1ZpE`U(5CoUFlkpTvHaK zmdE>tAEk_sKI$>nOd2Qeujnr^xJv5SW9N^DRy(>HK8eS}@^xW(_FjVb+6zzRc?85sxhdXS$xZm~0IuPfip8~}QlWa&l7D;RAa~*|E#4Q&g`f(^3Xx4O?r^(O0 z$adXlD%arG>I)P3*z|Y$)5oqemCd1M>V#D3Fm`V^Cif^Gk*cP@O?mv1+_Bj%*Inld zZ+6Y3pX73kleP)NE}KqX-KP(+3O5G7{mP!9rKY9o*5zgz#~BGvHo2PQy^G}wp?Hgt zkB63WrnPUXYqaZ_ruw7fc#nsoaciiw#80BiI09#6wY5xzP=*pw=oTHeXJHtggoWGT zWs0EswyPL|DaHuCfh|?6BoGb*{kNe%Cfp4YT1ATzzP4`XFX2vl7SiG;QP_);VKfO% zo`$l%Z6Xx~`AUeBeSa2Ky0cF|b;RCW8e}Ru!>y?^nYM&=mp@xSED?Y#44{IYmct%2 z1f6`+JvxwKHpB?F6R2r%P-$?xflpM#T%BLEi1rP4!{a=(~Lf!`Wa}7kd^j;NdRceP?a$jSgP%K$1I1Jb` zXZzN0P&v@?>&dR(17lz+e`<346mw$1D_&Dn&mvP8RqFGzB zqq7q|^C-HDvZJt0s2mO$5FZN4Qm9X%QF&$y5=hF9zFfdx%0mJ%_yl%gScZMs$al^g zCqsNskVA+rpuEoKy~i2+I8o`jl{lUHsfqdhp0fgTl)uNKKl=0CgLW5r$)Iw?&)wHuWqNdyxE0X%ihZ?iMGauKGd zzleLn6+mwZ-8SSn$H7o zHJLisTI=to4^?LtWabVJU3qYB9`HII)+(=Kp)#M^l+infjk~u3VPeYsUBy)7OJJWR zvM~GtPS5udv4n``* z&kowkYYPSc$I0+?X4D@NMria|BIwmr&Z>U4nKz*?c0m=JxiJN0zVWFcu)xroH9B(( z;h2Hz$&g{FI3TcLq{(#Bn`sMco*cBt-T^u*N>q`i@xAerUkHMXszkbHFx_^}o^|UW z?z~BJ+)#2$*MRt}cad4_Ovti{ZN*lQEC23##erc*TrZ&;hmjmZWeD*I`P7lk!qY^o zDrc$kRvDMoVI?P@5T;YqDII67*R9M_x3t@G$kSLDtNG(Nts)zAkGyrvVWv7lUryXHWM& zd`Qk~oSvj^&N%iuSg8s^Xd;i<$%r#S2KtFqE?{g-1ukq1s zs7>^bvt{@xHiEN!1%C}$0Gy*@A`S2A6SG4+Jf^b$M_IP``A?Ng8 z_itC|?~w4yDdE6WvDbv>ek!hN<&jX2i&z|FeJKB;lxi_Dlo}3xYr>f`!^VqGx8PW} z^-z=qf-$WJy_3vA&x)Ms1n}^I;gGPh7ZiI235-?2#va+sjHwD0)22JUrE1T%&MNlY zt9ZkAyx*;#@a`sY`}cS5O8TQ5yfc;RsrveGOw*S_Rjf>7sbMV2dA-&RgMDr0V3TRe zm?I>|!T}Qc_K;uW6N@#VW$wPZhsM!OdrbpjBPaIf$4?*imLBY`W`MGB-9&k^L!@wf zD%}49IQ}{f>e|evqf0#x({0e>f2j1ZrT6^6TT6jUibayXGIf}-d9TO~?Tn(NuaTpA zHt8{;s$(UGnytUqlbwK!ckGKZT^tCCzx`ip`%g}^w=Io*iqeUh`-PpaQFkIGemzJo}cAlZVlNLL! z@ou5ej1$VEuy_$lKpD*j21C0H8o!SV7^!cE1oYh1a*BBLrh!c*Qa&PzQDqaLchthl zz5iJy*M`gVBGcBZ>wX7%jlN7^{&Dt1r4u*LHapWGa665WVb-Da+k>z*XL?_6o@z=z zk!*n>K)5)po@rRki@jSkx2Mr^S!Hsqb{2?IaU1MIv6F{QKg}!7rJwwP)%_uB^d~y2 zz!-ct!>j-ZFMbXjc%M6N+`4M8>&kk$(8`x09$gSd$O$Yo%}^6?4$oLbK>dfz6T<3*{MAD-amg-1E>G&8noX z7K<4I>O_R}xc9%P74jlLi>G25yIVcl=$wJ{+kyRGblfpo-OJ;r2aBCOxT!cqV1cOW z$0NN=hz^dLk3Enp1l&Ok{zazeOZTI6>t_c|o)2>VPS)?IOumj~HI7FmlvfV#-~U06T!zUI@~>URSK=mM>|iQpTkjzr%0Q6T%O+idOI{xXrgZ)&xn}0 zlVJ9pC2>~h#S_#5$?&?CIo5O^1_>>57E3`}c^j!?(B%gSE7M*)_^oP6j`I+FmU)3- z=awG@p=N&(>hNP3A(|?b-Xnfb%TJxK6vbq13T9(u-|hF}m3zdcoteHj>e}Y}HcvOHFV^Nxm)I`D<2~XU znXm=A1{U2Al^PBKyD5v05N>@1%Q}D$4f4(tPUX;I}`h^cWamy{MvPyFmF= zc|Z`#l7@rOQB@Akhms8h{Jm6iPyI<%%@tRS)aNK4N(F;xmt;cnsBl<3iCd8ABcfM! z$EEY%_N{ZhKRQb)xxxDp=w$zy(f;(|50i}k0$q9FH5!{|=T3#3 z+Pb85f!7u6(YMcWep#6;SO}?&NcHb8)6)>m(>lDLY8U8iAPK`L`r3NPvHWeSAZ;^m zxm$<6<n7jEmQ9ANol{O4j8z0q<_oE`UG1IY6q%kbZ_%4D0E{DX7<-ye|Z{6Al_ zzgex&LQx(Sr!EBvVsDWK@i;}q^;x%0$g@-pk^Y?Zm|@lnd2>!q?f>I>4gdS^ue|zS KYkr+k@P7f&sc#Jc literal 22713 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 Date: Mon, 17 Aug 2026 21:12:13 +0300 Subject: [PATCH 214/218] update styling --- screenshots/widgets.png | Bin 72796 -> 73135 bytes src/WIDGETS/ELRSTelemetry/ui/components.lua | 84 ++++++++++++-------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 14fc626f5bafed93ca34c2410a07aa6a21ee26e8..5fd8ef9fca5b291e7a4f8bb430e8ca2f3d208292 100644 GIT binary patch literal 73135 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⋘f@X&=Qx>4{&XrPk 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 KYkr+k@P7f&sc#Jc diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index c4e5c4b..4ac33aa 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -196,6 +196,10 @@ end function Components.bar(dst, rect, y, spec) local h = spec.h local w = rect.w + -- Pill ends. rounded is a build-time corner radius, which is fine here: the + -- bar's height is fixed per build, and LVGL clamps the radius itself when + -- the fill's live width drops below the pill's diameter. + local r = math.floor(h / 2) dst[#dst + 1] = { type = lvgl.RECTANGLE, x = rect.x, @@ -210,6 +214,7 @@ function Components.bar(dst, rect, y, spec) color = COLOR_THEME_DISABLED, opacity = TRACK_OPACITY, filled = true, + rounded = r, } dst[#dst + 1] = { type = lvgl.RECTANGLE, @@ -219,6 +224,7 @@ function Components.bar(dst, rect, y, spec) h = h, color = spec.color, filled = true, + rounded = r, -- Size closures go through luaL_checkunsigned, so this must never return -- a negative, and never 0 either: 0 is a literal zero-pixel object rather -- than "no width". An empty bar hides via spec.visible instead. @@ -866,7 +872,10 @@ function Components.fullTier(w, h, opa, m, spec) + m.sml * #rows + (wide and 0 or m.sml + m.gap) -- narrow puts TX POWER on its own row local slack = f.h - fixed - local barH = math.max(4, math.min(math.floor(slack * 0.3), 26)) + -- A gentle fraction of the slack: the tier also serves zones barely over + -- the 1/2 breakpoint, where a generous cut of a small slack makes the bars + -- thicker than the rows they sit under. The tall 1/1 still reaches the cap. + local barH = math.max(4, math.min(math.floor(slack * 0.15), 18)) local air = math.max(m.gap, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} @@ -934,7 +943,7 @@ function Components.halfTier(w, h, opa, m, spec) end local slack = f.h - fixed - local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.35), 20)) + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.28), 14)) local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} @@ -980,7 +989,7 @@ function Components.thirdTier(w, h, opa, m, spec) + (m.sml + 4) -- status strip + spec.lqH local slack = f.h - fixed - local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.5), 14)) + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.45), 12)) local air = math.max(0, math.floor((slack - barH) / AIR_GAPS)) local root = {} @@ -1027,12 +1036,13 @@ end --- with it. Two rows cannot hold a strip and a bar, and the bar is what this --- widget is: below the strip the choice is between a row of numbers any --- telemetry screen could show and the one instrument only this widget has. ---- The RSSI pair keeps the left edge because the bar grows from there and plots ---- exactly that reading. LQ goes to the right, so it reads as the number it is ---- rather than as the bar's label. +--- LQ leads beside the name and the dBm pair takes the right margin -- the +--- same ends every taller tier gives them, so resizing the widget never +--- swaps the side a reading lives on. --- Then the packet rate and power come back as one cell where the width allows, ---- and the name after them -- readings before identity, since the mode and the ---- power are things only this row can tell you at this size. +--- and the name leads the row from beside the LED, as it does on the status +--- strip -- a mark trailing at the end of the line reads as an afterthought, +--- and a row that ends on a reading ends on something worth reading. --- The LED keeps its lane, so a link that is down still says so on the tier --- with no room to say it in words, and the hero label falls back to the status --- text: there is no strip here to carry a banner. @@ -1065,7 +1075,10 @@ function Components.compactTier(w, h, opa, m, spec) -- With no bar the row has the panel to itself, so it centres; with one it -- keeps the top and the bar takes the slack. local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) - local textX = Components.ledLane(panel, inner, y, m) + -- The LED sits beside the dropped small text, not beside the hero, so it + -- centres on the dropped line: undropped it rides high of the reading by + -- the whole baseline offset, which at this size is plainly visible. + local textX = Components.ledLane(panel, inner, y + drop, m) local right = inner.x + inner.w local detailW = Components.textWidth("K1000Full 2000mW", SMLSIZE) @@ -1081,53 +1094,56 @@ function Components.compactTier(w, h, opa, m, spec) local brand = Components.brandText(avail) if brand then - local brandW = Components.textWidth(brand, SMLSIZE) + -- The name leads the row from beside the LED, the same grammar as the + -- status strip -- so the line ends on a reading, not on the mark. The + -- readings shift right by the name's width and the gap is the strip's + -- name gap, wide enough that name and reading do not run together. Components.label(panel, { - x = right - brandW, + x = textX, y = y + drop, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = brand, }) - right = right - brandW - m.pad * 2 + textX = textX + Components.textWidth(brand, SMLSIZE) + m.pad * 2 end - if showDetail then - Components.label(panel, { - x = right - detailW, - y = y + drop, - w = detailW, - align = RIGHT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - -- Hidden with the other readings while the hero carries a status, which - -- is the one case a long hero string reaches this far across the row. - visible = Display.isNotMismatch, - }) - right = right - detailW - m.pad - end - Components.label(panel, { x = textX, y = y, - w = right - textX, - align = RIGHT, font = spec.lqFont, color = Display.heroColor, -- The status when there is one: no strip means no banner, so this label is - -- the only thing that can say the link is down or the model is wrong. It is - -- right-aligned, so a long status grows leftward across the row it has just - -- emptied rather than into the mark at its end. + -- the only thing that can say the link is down or the model is wrong. Left + -- aligned, a long status grows rightward across the row the hidden + -- readings have just emptied. text = Display.heroText, }) + + local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) Components.label(panel, { - x = textX, + x = right - signalW, y = y + drop, + w = signalW, + align = RIGHT, font = SMLSIZE, color = Display.detailColor, text = Display.signalText, visible = Display.isNotMismatch, }) + if showDetail then + Components.label(panel, { + x = right - signalW - m.pad * 2 - detailW, + y = y + drop, + w = detailW, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.rfDetailText, + -- Hidden with the other readings while the hero carries a status, which + -- is the one case a long hero string reaches this far across the row. + visible = Display.isNotMismatch, + }) + end if barH > 0 then Components.bar(panel, inner, vpad + spec.lqH + air, { From c7f0abdc3881c9bf3c19e45a154325f00b027e22 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 22:17:24 +0300 Subject: [PATCH 215/218] update design --- screenshots/widgets.png | Bin 73135 -> 73561 bytes src/WIDGETS/ELRSTelemetry/ui/components.lua | 52 +++++++++++--------- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 4 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 4 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 4 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 4 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 4 +- 7 files changed, 38 insertions(+), 34 deletions(-) diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 5fd8ef9fca5b291e7a4f8bb430e8ca2f3d208292..098bb46666050ed9be3699b5d12d2038d12a6be5 100644 GIT binary patch literal 73561 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

;85Xzh@Qd-9JV1g;A6`9|Y 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 MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.6), 12)) or 0 + -- Capped where the taller tiers' fractions land in practice, so a widget + -- resized across the ladder keeps one bar weight: a roomy 1/4 zone used to + -- fill toward this cap's double while every other size drew ~6px. + local barH = slack >= MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.3), 6)) or 0 local air = math.max(0, slack - barH) -- Small type beside a large number sits on its baseline, not its top. local drop = math.max(0, spec.lqH - m.sml) @@ -1107,19 +1111,33 @@ function Components.compactTier(w, h, opa, m, spec) }) textX = textX + Components.textWidth(brand, SMLSIZE) + m.pad * 2 end + if showDetail then + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.rfDetailText, + -- Hidden with the other readings while the hero carries a status. + visible = Display.isNotMismatch, + }) + textX = textX + detailW + m.pad * 2 + end + local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) + -- LQ's box spans the middle, right-aligned against the dBm pair: normally + -- only the reserved right end of it is inked, and a status -- the one long + -- string this label ever carries -- grows leftward across the row the + -- hidden readings have just emptied. + local heroRight = right - signalW - m.pad * 2 Components.label(panel, { x = textX, y = y, + w = math.max(1, heroRight - textX), + align = RIGHT, font = spec.lqFont, color = Display.heroColor, - -- The status when there is one: no strip means no banner, so this label is - -- the only thing that can say the link is down or the model is wrong. Left - -- aligned, a long status grows rightward across the row the hidden - -- readings have just emptied. text = Display.heroText, }) - - local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) Components.label(panel, { x = right - signalW, y = y + drop, @@ -1130,20 +1148,6 @@ function Components.compactTier(w, h, opa, m, spec) text = Display.signalText, visible = Display.isNotMismatch, }) - if showDetail then - Components.label(panel, { - x = right - signalW - m.pad * 2 - detailW, - y = y + drop, - w = detailW, - align = RIGHT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.rfDetailText, - -- Hidden with the other readings while the hero carries a status, which - -- is the one case a long hero string reaches this far across the row. - visible = Display.isNotMismatch, - }) - end if barH > 0 then Components.bar(panel, inner, vpad + spec.lqH + air, { diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index aea08fd..0c6b750 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -30,7 +30,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - compact = { hero = BOLD }, + compact = { hero = SMLSIZE }, third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = STDSIZE, detail = 0 }, } @@ -45,7 +45,7 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = --- is shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) end --- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index a37ab6e..069248c 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -29,7 +29,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - compact = { hero = BOLD }, + compact = { hero = SMLSIZE }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -44,7 +44,7 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = --- is shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) end --- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 759bdd3..686bbb1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -32,7 +32,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - compact = { hero = BOLD }, + compact = { hero = SMLSIZE }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -47,7 +47,7 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = --- is shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) end --- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index cdd5ff6..571b2f8 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -31,7 +31,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - compact = { hero = BOLD }, + compact = { hero = SMLSIZE }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, } @@ -46,7 +46,7 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = --- is shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) end --- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index c9b4c62..5671aac 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -31,7 +31,7 @@ WidgetUI.breakpoints = { } WidgetUI.fonts = { - compact = { hero = BOLD }, + compact = { hero = SMLSIZE }, third = { hero = BOLD, detail = SMLSIZE }, full = { hero = BOLD, detail = SMLSIZE }, } @@ -46,7 +46,7 @@ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = --- is shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.bold }) + Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) end --- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. From 24bc07f0219ed2c688510e5886ef7486f3d8c040 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Mon, 17 Aug 2026 23:42:20 +0300 Subject: [PATCH 216/218] experiment --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 995 +++++++------------- src/WIDGETS/ELRSTelemetry/ui/display.lua | 118 +-- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 51 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 44 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 52 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 50 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 50 +- src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 361 ++++--- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 263 +++--- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 275 ++---- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 261 ++--- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 273 +++--- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 222 ++--- 13 files changed, 1219 insertions(+), 1796 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index b317637..92227b5 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -3,11 +3,11 @@ -- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- -- (Display); returns the Components table. -- -- -- --- Two layers. Elements are the shapes -- panel, bar, led, segments, -- --- rule, label. Blocks are what a tier actually places: a status strip, -- --- the uplink panel, a row of captioned groups. Keeping the blocks here -- --- is what makes five per-screen files affordable: a tier builder stays -- --- a short vertical composition instead of eight inlined element -- +-- Two layers. Elements are the shapes -- panel, bar, led, antenna, -- +-- label. Blocks are what a tier actually places: the header row, the -- +-- LQ hero, a captioned value row, the reading grid. Keeping the blocks -- +-- here is what makes five per-screen files affordable: a tier builder -- +-- stays a short vertical composition instead of eight inlined element -- -- definitions copied five times. -- -- -- -- Nothing here calls lvgl.build(). Every function appends descriptor -- @@ -32,23 +32,31 @@ local Components = {} local TRACK_OPACITY = 90 -- The widget's own name, and the short form for a zone too narrow for it. --- Written the way ExpressLRS writes it rather than uppercased like the inline --- headers further down: this is a name, not a caption, and the same reason +-- Written the way ExpressLRS writes it rather than uppercased like the +-- captions further down: this is a name, not a caption, and the same reason -- keeps the mixed case in TQly and TRSS. local BRAND = "ExpressLRS" local BRAND_SHORT = "ELRS" --- The mismatch banner, and the form for a strip that carries the name as well. --- Caps either way: this is the one thing on the strip worth interrupting for. +-- The mismatch banner, and the short form for a header that cannot carry the +-- long one. Caps either way: this is the one thing on the header worth +-- interrupting for. local MISMATCH = "MODEL MISMATCH!" local MISMATCH_SHORT = "MISMATCH!" ---- Side of one antenna cell. Half the row, so the pair sits at the LED's ---- weight rather than the text's. +--- Side of one antenna cell. Half the row, so the pair sits at the status +--- dot's weight rather than the text's. local function antCell(m) return math.floor(m.sml / 2) + 1 end +--- Radius of the status dot. Deliberately smaller than the row: an indicator +--- only has to be seen, and at half the row height it stops reading as a dot +--- and starts competing with the text beside it. +local function dotRadius(m) + return math.floor(m.sml / 4) + 1 +end + -- ============================================================================ -- Metrics -- ============================================================================ @@ -64,13 +72,26 @@ function Components.measure() local m = { pad = lvgl.PAD_SMALL, gap = lvgl.PAD_TINY, + lines = {}, } - m.sml = select(2, lcd.sizeText("0", SMLSIZE)) - m.bold = select(2, lcd.sizeText("0", BOLD)) - m.mid = select(2, lcd.sizeText("0", MIDSIZE)) + m.sml = Components.lineHeight(m, SMLSIZE) + m.bold = Components.lineHeight(m, BOLD) + m.mid = Components.lineHeight(m, MIDSIZE) return m end +--- Line height of a font, measured once per font and cached on the metrics +--- table. This is what lets a screen file declare its hero ladder as font +--- names alone: the height half of the pair is always the measured truth. +function Components.lineHeight(m, font) + local h = m.lines[font] + if h == nil then + h = select(2, lcd.sizeText("0", font)) + m.lines[font] = h + end + return h +end + --- Width of a string in a font, for reserving a column. function Components.textWidth(s, font) return (lcd.sizeText(s, font)) @@ -99,8 +120,8 @@ end --- The widget's name in the longest form that fits availW, or nil when even --- the short one does not. ---- A ladder rather than a per-radio breakpoint, for the same reason the group ---- row has one: what fits is a measured width in this screen's font. +--- A ladder rather than a per-radio breakpoint: what fits is a measured width +--- in this screen's font. function Components.brandText(availW) if availW >= Components.textWidth(BRAND, SMLSIZE) then return BRAND @@ -111,11 +132,23 @@ function Components.brandText(availW) return nil end ---- The name in the form the panel's top row can carry beside the LQ reading. ---- Measured against the widest reading rather than the current one, so the ---- mark does not move when the number gains a digit. -function Components.panelBrand(w, m, lqFont) - return Components.brandText(w - Components.textWidth("LQ 100%", lqFont) - m.pad * 3) +--- The largest hero font whose line box fits the budget, with its measured +--- height. The ladder exists because one tier builder serves more than one +--- zone height -- on 480x272 the 1/1 composition draws both the 227px full +--- zone and the 113px half zone -- so the hero has to be picked from what the +--- zone can afford, not declared per tier. The last rung is the floor: it is +--- taken even when over budget, because a panel with no hero is not this +--- widget. +function Components.heroFromLadder(m, ladder, budget) + for i = 1, #ladder do + local font = ladder[i] + local fh = Components.lineHeight(m, font) + if fh <= budget then + return font, fh + end + end + local last = ladder[#ladder] + return last, Components.lineHeight(m, last) end -- ============================================================================ @@ -132,6 +165,8 @@ end --- a card glued over the background while every widget beside it is painted on --- -- and a border also eats a pixel off each side of the content box, which is --- enough to make symmetric padding come out lopsided. +--- spec.rounded is the card's corner radius, on the fill alone: the container +--- draws nothing, so its square corners cannot show. function Components.panel(dst, rect, spec) local children = {} dst[#dst + 1] = { @@ -143,6 +178,7 @@ function Components.panel(dst, rect, spec) color = COLOR_THEME_PRIMARY2, opacity = spec.opacity, filled = true, + rounded = spec.rounded, } dst[#dst + 1] = { type = lvgl.RECTANGLE, @@ -156,25 +192,6 @@ function Components.panel(dst, rect, spec) return children end ---- A 1px horizontal rule. ---- A filled rectangle, not an hline: hline has no thickness property, an ---- unknown key raises, and lvgl.build() swallows the error along with the ---- whole tree. ---- COLOR_THEME_SECONDARY2 is the obvious choice for a divider and the wrong ---- one: against COLOR_THEME_PRIMARY2, at 1px, it simply does not appear. -function Components.rule(dst, rect, y) - dst[#dst + 1] = { - type = lvgl.RECTANGLE, - x = rect.x, - y = y, - w = rect.w, - h = 1, - color = COLOR_THEME_DISABLED, - filled = true, - } - return y + 1 -end - --- A text label. Thin wrapper, so a tier never hand-writes a descriptor. function Components.label(dst, spec) dst[#dst + 1] = { @@ -242,29 +259,6 @@ function Components.bar(dst, rect, y, spec) return y + h end ---- The LED in a fixed lane at the left of a row, returning the x where text ---- starts. The lane is what everything after it measures from, so the dot's ---- radius can be tuned without the readings shifting sideways. ---- Shared with the tiers that have no status strip, so the LED sits at one ---- x on every tier and a widget resized between them does not appear to move ---- its indicator. -function Components.ledLane(dst, rect, y, m) - local lane = math.floor(m.sml / 2) + 2 - Components.led(dst, { - x = rect.x + math.floor(lane / 2), - -- Centred on the text's line box, not on the row. The row is the line box - -- plus padding, so centring in it drops the dot below the text it sits - -- beside -- close enough to look like a mistake rather than a choice. - y = y + math.floor(m.sml / 2), - -- Deliberately smaller than the lane. An indicator only has to be seen, and - -- at half the row height it stops reading as a dot and starts competing - -- with the text beside it. - radius = math.floor(m.sml / 4) + 1, - color = Display.ledColor, - }) - return rect.x + lane + m.pad -end - --- The status LED. x and y are the centre, not a corner. function Components.led(dst, spec) dst[#dst + 1] = { @@ -277,100 +271,22 @@ function Components.led(dst, spec) } end ---- A stepped meter: a continuous track and fill, cut into cells by separators ---- drawn on top in the panel colour. ---- One size closure for the whole meter rather than one per cell, which is ---- the difference between 1 and 8 pcalls a frame. The separators are static, ---- so the fill reads as discrete steps without any cell knowing the value. ---- Outlining the unlit cells instead was tried and is not visible: a ---- COLOR_THEME_SECONDARY2 hairline disappears into COLOR_THEME_PRIMARY2 on ---- the light themes, which took the meter's scale with it -- and a meter ---- whose empty half cannot be seen is just a short bar. ---- spec.visible hides the whole meter, cells and separators included. Every ---- piece needs it: a track left behind while its header and reading hide is ---- what a banner drawn over the strip runs into. -function Components.segments(dst, rect, y, spec) - local n = spec.count - local cw = math.floor(rect.w / n) - local h = spec.h - local total = cw * n - dst[#dst + 1] = { - type = lvgl.RECTANGLE, - x = rect.x, - y = y, - w = total, - h = h, - color = COLOR_THEME_DISABLED, - opacity = TRACK_OPACITY, - filled = true, - visible = spec.visible, - } - dst[#dst + 1] = { - type = lvgl.RECTANGLE, - x = rect.x, - y = y, - w = total, - h = h, - color = spec.color, - filled = true, - size = function() - local lit = spec.steps() - if lit > n then - lit = n - end - return math.max(1, lit * cw), h - end, - -- At zero steps the size closure would still have to return 1px, so the - -- meter empties by hiding the fill rather than by shrinking it. - visible = function() - if spec.visible and not spec.visible() then - return false - end - return spec.steps() > 0 - end, - } - for i = 1, n - 1 do - dst[#dst + 1] = { - type = lvgl.RECTANGLE, - x = rect.x + i * cw - 1, - y = y, - w = 1, - h = h, - color = COLOR_THEME_PRIMARY2, - filled = true, - visible = spec.visible, - } - end - return y + h -end - --- Two antenna cells, the active one lit. --- The best pixels-per-bit element either reference widget has: it says which --- RF path is carrying the link in 20px, where a text row costs a whole line. --- The second cell hides on a single-path receiver rather than sitting dark, --- which would imply an antenna that is not there. --- The whole group hides on a mismatch, along with everything else the banner ---- displaces: the strip is not wide enough to hold both, and cells left behind ---- are what "MODEL MISMATCH!" ends up printed across. ---- Sized off the LED beside them rather than off the row: at row height the two ---- cells are solid blocks that take the eye before any reading on the strip ---- does, which is backwards for a qualifier. As a pair of small marks they read ---- as the same class of thing the LED is, and they cost the strip less width. -function Components.antenna(dst, rect, y, m, labelled) +--- displaces: the header is not wide enough to hold both, and cells left +--- behind are what "MODEL MISMATCH!" ends up printed across. +--- Sized off the status dot beside them rather than off the row: at row +--- height the two cells are solid blocks that take the eye before any reading +--- does, which is backwards for a qualifier. As a pair of small marks they +--- read as the same class of thing the dot is. +function Components.antenna(dst, rect, y, m) local cell = antCell(m) local gap = m.gap local cx = rect.x - if labelled then - Components.label(dst, { - x = rect.x, - y = y, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = "ANT", - visible = Display.isNotMismatch, - }) - cx = rect.x + Components.textWidth("ANT ", SMLSIZE) - end for i = 1, 2 do dst[#dst + 1] = { type = lvgl.RECTANGLE, @@ -394,112 +310,76 @@ end -- Blocks -- ============================================================================ ---- The status strip: LED, the name, RF mode, optionally the power meter, ---- antenna cells. Returns the next free y and whether it took the name, so a ---- tier with no name on its strip can put one on the panel instead. ---- The name belongs here because the strip is the module row -- what it is ---- transmitting at, on which antenna -- so "ExpressLRS 150Hz" reads as a ---- header and its value, which is the grammar the captioned groups already ---- use. It stays through a mismatch: knowing which widget is shouting is ---- worth more then than at any other time. ---- A model mismatch takes the rest of the strip. The binding is wrong, which ---- is worth the row -- and the elements it displaces take the inverse visible ---- rather than being left out, so the banner lands in the same rect and ---- nothing below it moves. +--- The header row: the name and the RF mode on the left, the antenna cells +--- and the status dot pinned to the right corner. Returns the next free y. +--- The name belongs here because the header is the module row -- what it is +--- transmitting at, on which antenna -- so "ExpressLRS 250Hz" reads as a +--- header and its value, the same grammar the grid's captioned cells use. +--- spec.detail swaps the RF mode for the mode-plus-power form, for tiers with +--- no grid row left to carry the power reading. +--- A model mismatch takes the row. The binding is wrong, which is worth it -- +--- and the elements the banner displaces take the inverse visible rather than +--- being left out, so the banner lands in the same rect and nothing below it +--- moves. The dot stays: it is the blink saying the same thing. --- Only a mismatch gets a banner. "No telemetry" is what the widget shows on --- the bench with the quad switched off; shouting about a resting state --- teaches the eye to ignore the banner that matters. -function Components.statusStrip(dst, rect, y, m, spec) +function Components.headerRow(dst, rect, y, m, spec) local h = m.sml + 4 - local textX = Components.ledLane(dst, rect, y, m) - - -- Right-hand end, laid out from the right edge inward. Measured before the - -- name because it is what the name has to fit inside of. - local antW = Components.textWidth("ANT ", SMLSIZE) + 2 * antCell(m) + m.gap - local antX = rect.x + rect.w - antW - -- The mode's x is reserved against the widest rate name the tables carry, so - -- switching from 25Hz to K1000Full never pushes what follows sideways. - local modeW = Components.textWidth("K1000Full", SMLSIZE) - -- Measured against the power reading rather than the whole meter, so the - -- name outranks the meter's cells but never its number. The cells only - -- illustrate a figure that stays on the strip either way, and where they go - -- the existing fallback below puts "50 mW" in the gap instead -- so the strip - -- keeps everything it was saying and gains the name. A widget that says whose - -- readings these are is worth more than a lit-cell count of one of them. - local powerW = 0 - if spec.power or spec.powerText then - powerW = Components.textWidth("2000 mW", SMLSIZE) + m.pad * 2 - end - -- The mode-to-power gap is only reserved when there is a power element to - -- separate the mode from. On a tier that gives TX power its own row this was - -- holding 12px open for nothing, and the name is what it held them from. - local gapToPower = powerW > 0 and m.pad * 3 or m.pad - -- Wider than the strip's other gaps. At one size, with only colour telling - -- the name from the reading, a tight gap leaves "ExpressLRS 150Hz" reading + local dotR = dotRadius(m) + local antW = 2 * antCell(m) + m.gap + local dotX = rect.x + rect.w - dotR + local antX = rect.x + rect.w - dotR * 2 - m.pad - antW + + -- The mode's x is reserved against the widest rate name the tables carry, + -- so switching from 25Hz to K1000Full never pushes what follows sideways. + local modeW = Components.textWidth(spec.detail and "K1000Full 2000mW" or "K1000Full", SMLSIZE) + -- Wider than the header's other gaps. At one size, with only colour telling + -- the name from the reading, a tight gap leaves "ExpressLRS 250Hz" reading -- as one run-together word. local nameGap = m.pad * 2 - local slack = antX - (textX + modeW + gapToPower + powerW) - local antLabelled = true - -- Only the full name goes on the strip. Here it is a header to the value - -- beside it, and an abbreviated header reads as a strip that ran out of - -- room; the same abbreviation on the panel row still reads as a mark. So a - -- strip with space for the short form only passes the name down instead. - local named = Components.brandText(slack - nameGap) == BRAND - if not named then - -- Short by less than the antenna caption is wide, so the caption is what - -- the name is standing behind. The cells are the reading -- which path is - -- carrying the link -- and "ANT" only says what they are, so on a strip - -- that has to choose, the word goes and the widget gets its name. A - -- half-width 480x272 zone misses the full name by a single pixel with the - -- word in place, which is not a margin worth an anonymous widget. - local bareW = 2 * antCell(m) + m.gap - local bareSlack = slack + (antW - bareW) - if Components.brandText(bareSlack - nameGap) == BRAND then - named, antLabelled, antW, slack = true, false, bareW, bareSlack - antX = rect.x + rect.w - antW - end - end - local nameX = textX - if named then - textX = textX + Components.textWidth(BRAND, SMLSIZE) + nameGap + local brand = Components.brandText(antX - m.pad - rect.x - modeW - nameGap) + local textX = rect.x + if brand then + textX = rect.x + Components.textWidth(brand, SMLSIZE) + nameGap end - -- The banner runs from wherever the readings start to the end of the strip, - -- so putting the name first takes width off it. Shortened rather than - -- squeezed: a banner is no use clipped, and "MISMATCH!" is unambiguous under - -- a name that has just said which link it is about. + -- The banner runs from wherever the readings start to the dot, because the + -- cells it displaces hide with it. Shortened rather than squeezed: a banner + -- is no use clipped, and "MISMATCH!" is unambiguous under a name that has + -- just said which link it is about. + local bannerLimit = rect.x + rect.w - dotR * 2 - m.pad local banner = MISMATCH - local bannerX = textX - if Components.textWidth(banner, BOLD) > rect.x + rect.w - bannerX then + if Components.textWidth(banner, BOLD) > bannerLimit - textX then banner = MISMATCH_SHORT end - -- Nothing left that fits, so the name yields the strip for as long as the + -- Nothing left that fits, so the name yields the row for as long as the -- mismatch stands. Last resort, and in the other direction from everywhere -- else here: the binding being wrong outranks even saying whose binding. - local nameHides = Components.textWidth(banner, BOLD) > rect.x + rect.w - bannerX + local nameHides = Components.textWidth(banner, BOLD) > bannerLimit - textX + local bannerX = textX if nameHides then - bannerX = nameX + bannerX = rect.x end - if named then + if brand then Components.label(dst, { - x = nameX, + x = rect.x, y = y, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, -- A constant, so it costs no closure and no per-frame string hash -- -- except in the one case where the banner needs its width. - text = BRAND, + text = brand, visible = nameHides and Display.isNotMismatch or nil, }) end - Components.label(dst, { x = textX, y = y, font = SMLSIZE, color = COLOR_THEME_PRIMARY1, - text = Display.rfModeText, + text = spec.detail and Display.rfDetailText or Display.rfModeText, visible = Display.isNotMismatch, }) Components.label(dst, { @@ -514,146 +394,67 @@ function Components.statusStrip(dst, rect, y, m, spec) visible = Display.isMismatch, }) - Components.antenna(dst, { x = antX }, y, m, antLabelled) - - -- The meter goes in the gap between the RF mode and the antenna cells, at - -- its natural width and only when it genuinely fits there. - local powerX = textX + modeW + gapToPower - local meterFits = powerX + Components.powerGroupWidth(m) <= antX - if spec.power and meterFits then - Components.powerGroup(dst, { x = powerX }, y, m) - elseif spec.power or spec.powerText then - -- No room for the meter, so the reading itself takes the gap. The meter is - -- the first thing to go and the number is the last, because "50 mW" still - -- answers the question a lit cell count only illustrates. - -- Anchored to the antenna cells, not left-aligned off the RF mode. Left - -- aligned it lands wherever the reserved width of "K1000Full" happens to - -- end, which on a half-width strip is hard against the cells -- and - -- "50 mW ANT" reads as one phrase. From the right the gap is the same at - -- every width, and the box still starts no earlier than the RF mode allows. - local right = antX - m.pad * 2 - local px = math.max(rect.x, math.min(powerX, right - Components.textWidth("2000 mW", SMLSIZE))) - Components.label(dst, { - x = px, - y = y, - w = math.max(1, right - px), - align = RIGHT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.powerText, - visible = Display.isNotMismatch, - }) - end - return y + h, named -end - ---- Width of the TX POWER group, so a caller can place it without stretching ---- it. The meter is sized off the row height rather than off the space ---- available: a meter that grows to fill its container drags its own reading ---- away from it, which leaves "50 mW" stranded at the far side of the strip ---- with no visible tie to the cells it belongs to. -function Components.powerGroupWidth(m) - return Components.textWidth("TX POWER ", SMLSIZE) - + Display.powerStepCount() * math.floor(m.sml / 2) - + m.pad - + Components.textWidth("2000 mW", SMLSIZE) + Components.antenna(dst, { x = antX }, y, m) + Components.led(dst, { + x = dotX, + -- Centred on the text's line box, not on the row. The row is the line box + -- plus padding, so centring in it drops the dot below the text it sits + -- beside -- close enough to look like a mistake rather than a choice. + y = y + math.floor(m.sml / 2), + radius = dotR, + color = Display.ledColor, + }) + return y + h end ---- TX POWER as an inline group: header, stepped meter, value. ---- Laid out left to right at its natural width, so the reading sits right ---- after the cells it describes. Any slack in the strip stays to its right. -function Components.powerGroup(dst, rect, y, m) - local headW = Components.textWidth("TX POWER ", SMLSIZE) - local valW = Components.textWidth("2000 mW", SMLSIZE) - local meterW = Display.powerStepCount() * math.floor(m.sml / 2) +--- The hero figure: the bare LQ number in the tier's display font, with the +--- "LQ %" caption dropped to its baseline beside it. The caption's x is +--- reserved against the widest reading, so the caption never moves when the +--- number loses a digit. +--- spec.right puts a small reading on the same baseline at the right edge, +--- for a tier that merges the hero and the RSSI row into one. +function Components.hero(dst, rect, y, m, spec) Components.label(dst, { x = rect.x, y = y, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = "TX POWER", - visible = Display.isNotMismatch, - }) - Components.segments(dst, { x = rect.x + headW, w = meterW }, y + 2, { - count = Display.powerStepCount(), - h = m.sml - 4, - visible = Display.isNotMismatch, - -- Accent, not COLOR_THEME_PRIMARY1. The lit cells are a filled area, and - -- the text colour used as a fill is a black bar sitting on a blue panel. - -- Power is also not a health reading -- 500 mW is not worse than 50 -- so - -- it deliberately stays off the green/amber/red ramp. - color = COLOR_THEME_FOCUS, - steps = Display.powerSteps, + font = spec.font, + color = Display.lqTextColor, + text = Display.lqHeroText, }) + -- Small type beside a large number sits on its baseline, not its top: + -- labels position from the top, so the caption drops by the difference. + local drop = math.max(0, spec.h - m.sml) Components.label(dst, { - x = rect.x + headW + meterW + m.pad, - y = y, - w = valW, - -- Left, so the reading starts a fixed gap from the meter. Right-aligning - -- it in a box wide enough for "2000 mW" would push "50 mW" away from the - -- cells again, which is the thing being fixed. - align = LEFT, + x = rect.x + Components.textWidth("100", spec.font) + m.pad, + y = y + drop, font = SMLSIZE, - text = Display.powerText, - visible = Display.isNotMismatch, - }) - return y + m.sml -end - ---- The uplink panel: the widget's identity. ---- Unheadered on purpose. The uplink is what you fly on -- if it degrades you ---- lose control, where a degraded downlink only makes the numbers stale -- so ---- it takes the whole upper body in a visual register nothing else competes ---- with. Naming it would imply a peer that does not exist here; the downlink ---- is a single captioned row further down. ---- LQ and RSSI each get a full-width bar under their own caption. The two ---- scales differ -- LQ is a fixed 0-100, the headroom track recalibrates with ---- the packet rate -- and comparing their lengths is the useful part: a full ---- LQ bar over a half-full headroom bar reads as "perfect right now, less ---- margin than there could be", which is exactly what is worth seeing early. ---- spec.lqBar and spec.headroomBar drop either bar for the smaller tiers; ---- spec.endpoints adds the headroom scale's numbers where width allows. ---- spec.brand puts the widget's name at the right end of the LQ row, for a ---- tier whose strip had no room for it. That is a mark, not a header: it names ---- the widget, in the one place on the panel nothing else uses, and it does not ---- claim the block below it is "the uplink" as a caption there would. -function Components.uplinkPanel(dst, rect, y, m, spec) - Components.label(dst, { - x = rect.x, - y = y, - font = spec.lqFont, - color = Display.lqTextColor, - text = Display.lqText, + color = COLOR_THEME_SECONDARY1, + text = "LQ %", }) - if spec.brand then + if spec.right then Components.label(dst, { x = rect.x, - -- Sat on the reading's baseline rather than its top. Labels position from - -- the top, so small type beside a large number has to be dropped by the - -- difference or it floats above the digits it sits beside. - y = y + math.max(0, spec.lqH - m.sml), + y = y + drop, w = rect.w, align = RIGHT, font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = spec.brand, + color = Display.detailColor, + text = spec.right, }) end - y = y + spec.lqH - if spec.lqBar then - y = Components.bar(dst, rect, y, { - h = spec.barH, - pct = Display.lqPct, - color = Display.lqBarColor, - }) + m.gap - end + return y + spec.h +end +--- A captioned value row: muted caption on the left, the reading right-aligned +--- on the same line. The bar a tier draws under it is its own call, so the row +--- reads the same with or without one. +function Components.valueRow(dst, rect, y, m, spec) Components.label(dst, { x = rect.x, y = y, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = "RSSI", + text = spec.caption, }) Components.label(dst, { x = rect.x, @@ -661,362 +462,229 @@ function Components.uplinkPanel(dst, rect, y, m, spec) w = rect.w, align = RIGHT, font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, + color = spec.color or COLOR_THEME_PRIMARY1, + text = spec.text, }) - y = y + m.sml - - if spec.headroomBar then - y = Components.bar(dst, rect, y, { - h = spec.barH, - pct = Display.headroomPct, - color = Display.headroomBarColor, - -- No rated floor means no scale, and a bar drawn against a guessed one - -- is worse than no bar. The track stays so the row keeps its height. - visible = Display.hasHeadroom, - }) - if spec.endpoints then - Components.label(dst, { - x = rect.x, - y = y, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.sensText, - }) - Components.label(dst, { - x = rect.x, - y = y, - w = rect.w, - align = RIGHT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = Display.ceilingText, - }) - y = y + m.sml - end - end - return y -end - ---- One row of `HEADER value value ...` groups laid side by side. ---- The inline header is what makes two groups share a row readably: a value ---- always belongs to the nearest header on its left. Groups get measured ---- widths, so a value gaining a digit never reflows the row. ---- groups is a list of { header = "DOWNLINK", w = n, values = { ... } }, where ---- each value is { text = fn, caption = "TQly" } and the caption is optional. ---- The downlink's captions are the sensor names EdgeTX itself puts in the ---- model's telemetry list, mixed case and all, so the row can be read straight ---- across to that list and to the module's own screen. ---- Width one group needs, measured against the widest string each of its ---- values can ever show. Columns sized this way never move when a reading ---- gains a digit, and a tier can ask before committing to a row. -function Components.groupWidth(m, group) - local w = 0 - if group.header ~= "" then - w = Components.textWidth(table.concat({ group.header, " " }), SMLSIZE) - end - for i = 1, #group.values do - local v = group.values[i] - if v.caption then - w = w + Components.textWidth(v.caption, SMLSIZE) + m.pad - end - w = w + Components.textWidth(v.sample, SMLSIZE) + m.pad * 2 - end - return w + return y + m.sml end -function Components.groupRow(dst, rect, y, m, groups) - -- Lay out on measured widths and hand the leftover to the gaps between - -- groups. Splitting the row evenly instead would give a captioned group the - -- same space as a bare one and overlap its caption with its own value. - local natural = 0 - for i = 1, #groups do - natural = natural + Components.groupWidth(m, groups[i]) - end - local spare = math.max(0, math.floor((rect.w - natural) / math.max(1, #groups))) - - local x = rect.x - for i = 1, #groups do - local g = groups[i] - local cx = x - -- An empty header drops the group name and lets the captions carry it. - -- Only the downlink may do that, and only because TQly and TRSS are - -- self-qualifying: the T prefix already means the transmitter end. - if g.header ~= "" then +--- The reading grid: two equal columns of caption-value cells, in rows. +--- The captions are the sensor names EdgeTX itself puts in the model's +--- telemetry list, mixed case and all, so the grid reads straight across to +--- that list and to the module's own screen. +--- Values sit a fixed gap after their caption, left-aligned, so a reading +--- gaining a digit grows into its own column's slack and nothing reflows. +--- rows is a list of rows, each a list of { caption, text } cells. +function Components.grid(dst, rect, y, m, rows) + local colW = math.floor((rect.w - m.pad) / 2) + for i = 1, #rows do + local row = rows[i] + for j = 1, #row do + local cell = row[j] + local cx = rect.x + (j - 1) * (colW + m.pad) Components.label(dst, { - x = x, + x = cx, y = y, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, - text = g.header, + text = cell.caption, }) - cx = x + Components.textWidth(table.concat({ g.header, " " }), SMLSIZE) - end - for j = 1, #g.values do - local v = g.values[j] - local capW = 0 - if v.caption then - Components.label(dst, { - x = cx, - y = y, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = v.caption, - }) - capW = Components.textWidth(v.caption, SMLSIZE) + m.pad - end - local cellW = capW + Components.textWidth(v.sample, SMLSIZE) + m.pad * 2 Components.label(dst, { - x = cx, + x = cx + Components.textWidth(cell.caption, SMLSIZE) + m.pad, y = y, - w = cellW - m.pad, - align = RIGHT, font = SMLSIZE, color = COLOR_THEME_PRIMARY1, - text = v.text, + text = cell.text, }) - cx = cx + cellW end - x = cx + spare + y = y + m.sml + if i < #rows then + y = y + m.gap + end end - return y + m.sml + return y end --- ============================================================================ --- The 1/1 tier --- ============================================================================ - ---- The downlink pair. TRSS always carries dBm: a bare -94 beside a ---- percentage invites reading it as one, and the two numbers on this row are ---- in different units. The unit is not something the width ladder may trade ---- away, so what gives instead is the pack total, then the row, then the ---- header. -local function downlinkGroup(headed) +--- The downlink-and-battery grid rows the 1/1 tier shows in full and the 1/2 +--- tier appends the second of when it has the height. PWR leads because it is +--- what the module is transmitting at, where the rest is what came back from +--- the aircraft. TRSS always carries dBm: a bare -94 beside a percentage +--- invites reading it as one. +local function gridRows() return { - header = headed and "DOWNLINK" or "", - values = { - { caption = "TQly", text = Display.tqlyText, sample = "100 %" }, - { caption = "TRSS", text = Display.trssText, sample = "-105 dBm" }, + { + { caption = "PWR", text = Display.powerText }, + { caption = "TQly", text = Display.tqlyText }, }, - } -end - -local function batteryGroup(compact) - local g = { - header = "BATTERY", - values = { - { text = Display.cellText, sample = "4S 3.75 V" }, - { text = Display.packText, sample = "15.20 V" }, + { + { caption = "BATT", text = Display.cellText }, + { caption = "TRSS", text = Display.trssText }, }, } - if compact then - g.values[2] = nil - end - return g end ---- Fit the downlink and battery groups into the width there is. ---- A ladder, not a breakpoint: what fits depends on the header and column ---- widths in this screen's font, and the answer already differs between a ---- 396px zone and a 198px one on the same radio. ---- Whole values come off, never half a group -- a lone TQly with no TRSS ---- beside it reads as a downlink with no signal rather than as a row that ran ---- out of room. The DOWNLINK header is the last thing to go, and it can go at ---- all only because TQly and TRSS name themselves. -local function groupRows(w, m) - local function fits(a, b) - local total = Components.groupWidth(m, a) - if b then - total = total + Components.groupWidth(m, b) - end - return total <= w - end - - if fits(downlinkGroup(true), batteryGroup(false)) then - return { { downlinkGroup(true), batteryGroup(false) } } - end - if fits(downlinkGroup(true), batteryGroup(true)) then - return { { downlinkGroup(true), batteryGroup(true) } } - end - if fits(downlinkGroup(true)) then - return { { downlinkGroup(true) }, { batteryGroup(true) } } - end - return { { downlinkGroup(false) }, { batteryGroup(true) } } -end +-- ============================================================================ +-- Tiers +-- ============================================================================ ---- The whole 1/1 tier: status strip, uplink panel, group rows. +--- The whole 1/1 tier: header, hero, the two bar rows, the reading grid. --- Lives here rather than in each screen file because after the components --- carry the drawing there is nothing screen-specific left in it but the --- fonts -- and five copies of the vertical arithmetic is exactly the drift --- the per-screen split is supposed to avoid. --- Height left over after the fixed rows goes into the bars and the air ---- around the rules, never to the bottom: a panel with a hole in it is what +--- between blocks, never to the bottom: a panel with a hole in it is what --- "looks basic" means. +--- LQ gets the hero and the first bar because the uplink is what you fly on; +--- the RSSI row under it keeps the rated floor in the text and draws its bar +--- against that floor, so a full LQ bar over a half-full RSSI bar still reads +--- as "perfect right now, less margin than there could be". function Components.fullTier(w, h, opa, m, spec) - local wide = spec.wide local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner - local rows = groupRows(inner.w, m) - -- Everything except the two bars and the air between blocks. This has to - -- match what the blocks below actually consume, AIR_GAPS included, or the - -- last row walks off the bottom of the panel. - local AIR_GAPS = 4 - local fixed = pad * 2 -- top and bottom - + (m.sml + 4) -- status strip - + 2 -- two rules - + spec.lqH -- LQ - + m.gap -- LQ bar to RSSI row + -- Everything except the hero, the two bars and the air between blocks. This + -- has to match what the blocks below actually consume, AIR_GAPS included, + -- or the last row walks off the bottom of the panel. + local AIR_GAPS = 3 + local MIN_BAR = 4 + local fixedNoHero = pad * 2 + + (m.sml + 4) -- header row + + m.gap -- hero to LQ bar + m.sml -- RSSI row - + (wide and m.sml or 0) -- headroom endpoints - + m.sml * #rows - + (wide and 0 or m.sml + m.gap) -- narrow puts TX POWER on its own row - local slack = f.h - fixed + + m.gap -- RSSI row to its bar + + (m.sml * 2 + m.gap) -- grid + local heroFont, heroH = + Components.heroFromLadder(m, spec.heroLadder, f.h - fixedNoHero - MIN_BAR * 2 - AIR_GAPS * m.gap) + + local slack = f.h - fixedNoHero - heroH -- A gentle fraction of the slack: the tier also serves zones barely over -- the 1/2 breakpoint, where a generous cut of a small slack makes the bars -- thicker than the rows they sit under. The tall 1/1 still reaches the cap. - local barH = math.max(4, math.min(math.floor(slack * 0.15), 18)) + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.12), spec.barH)) local air = math.max(m.gap, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) - local y, named = Components.statusStrip(panel, inner, pad, m, { power = wide }) - y = Components.rule(panel, inner, y + air) - y = Components.uplinkPanel(panel, inner, y + air, m, { - lqFont = spec.lqFont, - lqH = spec.lqH, - barH = barH, - lqBar = true, - headroomBar = true, - endpoints = wide, - -- A narrow strip has nothing to spare between the RF mode and the antenna - -- cells, and the LQ row has a whole empty right half. - brand = not named and Components.panelBrand(inner.w, m, spec.lqFont) or nil, + local y = Components.headerRow(panel, inner, pad, m, {}) + y = Components.hero(panel, inner, y + air, m, { font = heroFont, h = heroH }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.lqPct, + color = Display.lqBarColor, }) - y = Components.rule(panel, inner, y + air) + air - if not wide then - -- Half width has no gap in the strip wide enough for the meter, so TX power - -- takes its own row at the head of the captioned groups. Header, meter, - -- reading is the same shape DOWNLINK and BATTERY have, so it reads as one of - -- them rather than as something wedged under the strip -- and it leads them - -- because it is what the module is transmitting at, where the rows below it - -- are what came back from the aircraft. - y = Components.powerGroup(panel, inner, y, m) + m.gap - end - for i = 1, #rows do - y = Components.groupRow(panel, inner, y, m, rows[i]) - end + y = Components.valueRow(panel, inner, y + air, m, { + caption = "RSSI", + text = Display.signalText, + color = Display.detailColor, + }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + -- No rated floor means no scale, and a bar drawn against a guessed one + -- is worse than no bar. The track stays so the row keeps its height. + visible = Display.hasHeadroom, + }) + Components.grid(panel, inner, y + air, m, gridRows()) lvgl.build(root) end ---- The 1/2 tier: the same panel with the group rows and the rules taken out. ---- Both bars survive here, and that is the point of the whole layout. This is ---- the size the widget is most often placed at, and it is where an earlier ---- draft -- uplink and downlink as mirrored blocks -- could not keep even one ---- of them. The downlink row goes first because it is a whole group, and the ---- bars are what the widget is for. ---- TX power stays as the text in the strip; only the meter goes. +--- The 1/2 tier (a 1/3-height zone): the same card with the grid cut to what +--- fits. The header takes the power reading, the hero drops to the tier's own +--- font, and both bars survive -- they are what the widget is for. The +--- BATT/TRSS row comes back the moment the zone can hold it over bars still +--- thick enough to read, which is the rung the halfH breakpoint exists for. function Components.halfTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner - local battery = { - header = "BATTERY", - values = { { text = Display.cellText, sample = "4S 3.75 V" } }, - } + local heroH = Components.lineHeight(m, spec.heroFont) local AIR_GAPS = 2 -- Thinner than this and a bar is a line, not a meter. local MIN_BAR = 3 local fixed = pad * 2 - + (m.sml + 4) -- status strip - + spec.lqH - + m.gap + + (m.sml + 4) -- header row + + heroH + + m.gap -- hero to LQ bar + m.sml -- RSSI row + + m.gap -- RSSI row to its bar - -- Battery is a whole group, so it goes before either bar is squeezed below - -- the height at which it stops reading as a bar. That is the degradation - -- order the whole layout follows: lose a row, keep the instruments. - local showBattery = Components.groupWidth(m, battery) <= inner.w and (f.h - fixed - m.sml) >= MIN_BAR * 2 - if showBattery then - fixed = fixed + m.sml + -- The grid row is whole or absent, and it goes before either bar is + -- squeezed below the height at which it stops reading as a bar. That is + -- the degradation order the whole layout follows: lose a row, keep the + -- instruments. + local showGrid = (f.h - fixed - (m.sml + m.gap)) >= MIN_BAR * 2 + if showGrid then + fixed = fixed + m.sml + m.gap end local slack = f.h - fixed - local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.28), 14)) + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.28), spec.barH)) local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) - local y, named = Components.statusStrip(panel, inner, pad, m, { powerText = true }) - y = Components.uplinkPanel(panel, inner, y + air, m, { - lqFont = spec.lqFont, - lqH = spec.lqH, - barH = barH, - lqBar = true, - headroomBar = true, - brand = not named and Components.panelBrand(inner.w, m, spec.lqFont) or nil, + local y = Components.headerRow(panel, inner, pad, m, { detail = true }) + y = Components.hero(panel, inner, y + air, m, { font = spec.heroFont, h = heroH }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.lqPct, + color = Display.lqBarColor, + }) + y = Components.valueRow(panel, inner, y + air, m, { + caption = "RSSI", + text = Display.signalText, + color = Display.detailColor, + }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, }) - if showBattery then - Components.groupRow(panel, inner, y + air, m, { battery }) + if showGrid then + local rows = gridRows() + Components.grid(panel, inner, y + m.gap, m, { rows[2] }) end lvgl.build(root) end ---- The 1/3 tier: the strip, then LQ and the RSSI pair sharing a row, then the ---- headroom bar. ---- Three rows is all there is here, and the strip keeps one of them: it is what ---- carries the name, the packet rate, the power reading, the antenna and the ---- mismatch banner, all of which would otherwise have to be given up one at a ---- time. That is what pays for putting LQ and the RSSI pair on one row -- the ---- taller tiers give RSSI a caption row of its own, and here that row is the ---- bar's height instead. ---- Losing the caption costs nothing: "-85 / -108 dBm" carries its own unit, and ---- with one bar left there is no second reading it could be mistaken for. ---- The LQ bar is what goes. Between the two, the headroom bar is the one that +--- The 1/3 tier (a 1/4-height zone): header, the hero sharing its baseline +--- with the RSSI pair, one bar. +--- Losing the RSSI caption costs nothing: "-85 / -108 dBm" carries its own +--- unit, and with one bar left there is no second reading it could be +--- mistaken for. The bar is the RSSI one. Between the two, it is the one that --- says something the number beside it does not -- it recalibrates with the ---- packet rate -- where an LQ bar plots a percentage that is already legible as ---- a percentage. +--- packet rate -- where an LQ bar plots a percentage that is already legible +--- as a percentage. function Components.thirdTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner + local heroH = Components.lineHeight(m, spec.heroFont) local AIR_GAPS = 2 -- Thinner than this and a bar is a line, not a meter. local MIN_BAR = 3 local fixed = pad * 2 - + (m.sml + 4) -- status strip - + spec.lqH + + (m.sml + 4) -- header row + + heroH local slack = f.h - fixed - local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.45), 12)) + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.45), spec.barH)) local air = math.max(0, math.floor((slack - barH) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) - local y = Components.statusStrip(panel, inner, pad, m, { powerText = true }) + air - Components.label(panel, { - x = inner.x, - y = y, - font = spec.lqFont, - color = Display.lqTextColor, - text = Display.lqText, + local y = Components.headerRow(panel, inner, pad, m, { detail = true }) + y = Components.hero(panel, inner, y + air, m, { + font = spec.heroFont, + h = heroH, + right = Display.signalText, }) - Components.label(panel, { - x = inner.x, - -- On the LQ reading's baseline, like the panel's wordmark: labels position - -- from the top, so small type beside a large number has to be dropped by - -- the difference between them. - y = y + math.max(0, spec.lqH - m.sml), - w = inner.w, - align = RIGHT, - font = SMLSIZE, - color = Display.detailColor, - text = Display.signalText, - }) - y = y + spec.lqH + air - Components.bar(panel, inner, y, { + Components.bar(panel, inner, y + air, { h = barH, pct = Display.headroomPct, color = Display.headroomBarColor, @@ -1026,35 +694,32 @@ function Components.thirdTier(w, h, opa, m, spec) lvgl.build(root) end ---- The short tiers -- 1/4 and 1/6 -- as one composition: a row of readings over ---- the headroom bar, with the bar thinning and the row shedding cells as the +--- The short tiers -- 1/4 and 1/6 -- as one composition: a row of readings +--- over the RSSI bar, with the bar thinning and the row shedding cells as the --- zone shrinks. --- One function for both because the difference between them turned out to be --- a bar height and a cell count, both of which are measured here anyway. Two --- builders differing only in that were two places to fix the same bug. ---- The strip is what goes at this height, and the mode, power and antenna go ---- with it. Two rows cannot hold a strip and a bar, and the bar is what this ---- widget is: below the strip the choice is between a row of numbers any ---- telemetry screen could show and the one instrument only this widget has. +--- The header is what goes at this height, and the mode, power and antenna +--- cells go with it. Two rows cannot hold a header and a bar, and the bar is +--- what this widget is: below the header the choice is between a row of +--- numbers any telemetry screen could show and the one instrument only this +--- widget has. --- The name and the mode cell read as one phrase from the left -- the same ---- grammar as the strip's "ExpressLRS 250Hz", in one font and one colour -- ---- and the link readings pack the right margin: LQ, then the dBm pair at ---- the edge every taller tier gives it, beside the pair it qualifies. ---- Then the packet rate and power come back as one cell where the width allows, ---- and the name leads the row from beside the LED, as it does on the status ---- strip -- a mark trailing at the end of the line reads as an afterthought, ---- and a row that ends on a reading ends on something worth reading. ---- The LED keeps its lane, so a link that is down still says so on the tier ---- with no room to say it in words, and the hero label falls back to the status ---- text: there is no strip here to carry a banner. +--- grammar as the header's "ExpressLRS 250Hz", in one font and one colour -- +--- and the link readings pack the right margin: LQ, then the dBm pair, then +--- the status dot in the corner, where every taller tier puts it. +--- The hero label falls back to the status text: there is no header here to +--- carry a banner. function Components.compactTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner + local heroH = Components.lineHeight(m, spec.heroFont) -- Thinner than this and a bar is a line, not a meter. Below it the row takes -- the whole panel and centres in it, rather than sitting above a smear. local MIN_BAR = 3 local vpad = pad - local slack = f.h - (vpad * 2 + spec.lqH) + local slack = f.h - (vpad * 2 + heroH) if slack < MIN_BAR then -- A 1/6 zone is 28px on 480x272, which is the row and its padding and -- nothing else. So the padding gives: the left and right edges keep theirs, @@ -1064,33 +729,50 @@ function Components.compactTier(w, h, opa, m, spec) -- widget on the screen offers -- and losing it here is losing it at the -- size the widget is most often squeezed into. vpad = m.gap - slack = f.h - (vpad * 2 + spec.lqH) + slack = f.h - (vpad * 2 + heroH) end -- Capped where the taller tiers' fractions land in practice, so a widget -- resized across the ladder keeps one bar weight: a roomy 1/4 zone used to -- fill toward this cap's double while every other size drew ~6px. - local barH = slack >= MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.3), 6)) or 0 + local barH = slack >= MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.3), spec.barH)) or 0 local air = math.max(0, slack - barH) -- Small type beside a large number sits on its baseline, not its top. - local drop = math.max(0, spec.lqH - m.sml) + local drop = math.max(0, heroH - m.sml) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa }) + local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) -- With no bar the row has the panel to itself, so it centres; with one it -- keeps the top and the bar takes the slack. local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) - -- The LED sits beside the dropped small text, not beside the hero, so it - -- centres on the dropped line: undropped it rides high of the reading by - -- the whole baseline offset, which at this size is plainly visible. - local textX = Components.ledLane(panel, inner, y + drop, m) + + -- The status dot holds the right corner, as it does on every taller tier. + local dotR = dotRadius(m) local right = inner.x + inner.w + Components.led(panel, { + x = right - dotR, + -- Centred on the dropped small-text line the readings beside it sit on. + y = y + drop + math.floor(m.sml / 2), + radius = dotR, + color = Display.ledColor, + }) + local textRight = right - dotR * 2 - m.pad + local textX = inner.x + local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) local detailW = Components.textWidth("K1000Full 2000mW", SMLSIZE) - local avail = right - - textX - - Components.textWidth("-105 / -105 dBm", SMLSIZE) - - Components.textWidth("LQ 100%", spec.lqFont) - - m.pad * 3 + -- The hero box is reserved against the widest string the label can ever + -- carry -- the statuses, not just the reading. A fixed-width box that the + -- live text can outgrow would wrap rather than clip, and take the row with + -- it; "Model Mismatch" wrapping over the bar is exactly what this pays for. + local heroW = math.max( + Components.textWidth("LQ 100%", spec.heroFont), + Components.textWidth("No CRSF module", spec.heroFont), + Components.textWidth("Model Mismatch", spec.heroFont) + ) + -- Six pads: the name and the detail advance by two each, and the signal box + -- keeps two off the hero. Reserving fewer lets the advances eat into the + -- hero's own reserve. + local avail = textRight - textX - signalW - heroW - m.pad * 6 local showDetail = avail >= detailW + m.pad if showDetail then avail = avail - detailW - m.pad @@ -1098,10 +780,10 @@ function Components.compactTier(w, h, opa, m, spec) local brand = Components.brandText(avail) if brand then - -- The name leads the row from beside the LED, the same grammar as the - -- status strip -- so the line ends on a reading, not on the mark. The - -- readings shift right by the name's width and the gap is the strip's - -- name gap, wide enough that name and reading do not run together. + -- The name leads the row, the same grammar as the header -- so the line + -- ends on a reading, not on the mark. The readings shift right by the + -- name's width and the gap is the header's name gap, wide enough that + -- name and reading do not run together. Components.label(panel, { x = textX, y = y + drop, @@ -1123,34 +805,33 @@ function Components.compactTier(w, h, opa, m, spec) }) textX = textX + detailW + m.pad * 2 end - local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) -- LQ's box spans the middle, right-aligned against the dBm pair: normally -- only the reserved right end of it is inked, and a status -- the one long -- string this label ever carries -- grows leftward across the row the -- hidden readings have just emptied. - local heroRight = right - signalW - m.pad * 2 + local heroRight = textRight - signalW - m.pad * 2 Components.label(panel, { x = textX, y = y, w = math.max(1, heroRight - textX), align = RIGHT, - font = spec.lqFont, + font = spec.heroFont, color = Display.heroColor, text = Display.heroText, }) Components.label(panel, { - x = right - signalW, + x = textRight - signalW, y = y + drop, w = signalW, align = RIGHT, font = SMLSIZE, color = Display.detailColor, - text = Display.signalText, + text = Display.signalShortText, visible = Display.isNotMismatch, }) if barH > 0 then - Components.bar(panel, inner, vpad + spec.lqH + air, { + Components.bar(panel, inner, vpad + heroH + air, { h = barH, pct = Display.headroomPct, color = Display.headroomBarColor, @@ -1161,8 +842,4 @@ function Components.compactTier(w, h, opa, m, spec) lvgl.build(root) end --- ============================================================================ --- Return components --- ============================================================================ - return Components diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index b45a728..70cf971 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -118,23 +118,6 @@ function Display.trssText() return table.concat({ tostring(trss), " dBm" }) end ---- The rated floor on its own, e.g. "-108", for the headroom bar's left ---- endpoint. Empty when there is no floor, which is also when the bar hides. -function Display.sensText() - local sens = Telemetry.link.sens - if sens == nil then - return "" - end - return tostring(sens) -end - ---- The headroom bar's right endpoint, the RSSI above which more signal buys ---- nothing. A property of the scale, so it comes from the state, not a literal ---- in a layout file. -function Display.ceilingText() - return tostring(Telemetry.RSSI_CEILING) -end - --- TX power on its own, e.g. "100 mW", or "--" while unknown. function Display.powerText() local tpwr = Telemetry.link.tpwr @@ -172,6 +155,17 @@ function Display.lqText() return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) end +--- The bare LQ number for the hero figure, e.g. "99", or "--" while +--- disconnected. No caption and no unit: the tier draws those as its own +--- fixed label, so the number can take a display font without dragging a +--- "%" the same size along. +function Display.lqHeroText() + if not Telemetry.isConnected() then + return "--" + end + return tostring(Telemetry.link.rqly or 0) +end + --- Active antenna RSSI, e.g. "-87dBm", or "" while unknown. --- sep is the gap between the number and the unit: "" in tight minimized --- layouts, " " where the full-screen rows have room for it. @@ -188,11 +182,41 @@ function Display.heroText() return Display.statusText() or Display.lqText() end ---- Active RSSI against the rate's rated floor, e.g. "-90 / -112 dBm". +--- RSSI against the rate's rated floor, e.g. "-90 / -112 dBm". --- The pair is the point: RSSI alone says nothing until you know what the --- receiver can still hear at, and that figure moves with the packet rate. ---- Drops to the reading alone when the rate is unrated. +--- On diversity hardware both antennas appear ("-85 -92 / -112 dBm") in +--- fixed 1-2 order, so neither number jumps position when the RX switches +--- paths. Drops the floor when the rate is unrated. function Display.signalText() + if not Telemetry.isConnected() then + return "" + end + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + local parts + if Telemetry.hasDiversity() then + parts = { tostring(Telemetry.link.rssi1 or "--"), " ", tostring(Telemetry.link.rssi2) } + else + parts = { tostring(rssi) } + end + local sens = Telemetry.link.sens + if sens ~= nil then + parts[#parts + 1] = " / " + parts[#parts + 1] = tostring(sens) + end + parts[#parts + 1] = " dBm" + return table.concat(parts) +end + +--- The signal pair in its narrowest form: the active antenna alone against +--- the floor. The compact tier reserves a fixed box for this reading, and the +--- diversity pair is wider than the box can be without pushing the hero into +--- wrapping -- so the tightest tier shows the path the link is on, the same +--- trade its width ladder already makes with the antenna cells. +function Display.signalShortText() if not Telemetry.isConnected() then return "" end @@ -225,19 +249,6 @@ function Display.rfDetailText() return table.concat(parts, " ") end ---- Battery text for minimized layouts, e.g. "Bat 4S 3.80V". -function Display.batteryText() - local vbat = Telemetry.link.vbat - if vbat == nil or vbat <= 0 then - return "" - end - local cells = Telemetry.cellCnt - if cells then - return string.format("Bat %dS %.2fV", cells, vbat / cells) - end - return string.format("Bat %.2fV", vbat) -end - --- Battery text for the full-screen row, e.g. "4S 3.80V (15.20V)". function Display.batteryTextVerbose() local vbat = Telemetry.link.vbat @@ -379,32 +390,6 @@ function Display.ledColor() return Display.lqBarColor() end --- ExpressLRS power ladder in mW. The meter counts steps on this rather than --- scaling a percentage, because the steps are what the module actually offers --- and 100 mW is halfway up the ladder but 5% of the range. -local POWER_STEPS = { 10, 25, 50, 100, 250, 500, 1000, 2000 } - ---- How many power steps are lit, 0 to #POWER_STEPS. -function Display.powerSteps() - local tpwr = Telemetry.link.tpwr - if tpwr == nil then - return 0 - end - local n = 0 - for i = 1, #POWER_STEPS do - if tpwr >= POWER_STEPS[i] then - n = i - end - end - return n -end - ---- Total cells in the power meter, so a layout can size it without knowing ---- the ladder. -function Display.powerStepCount() - return #POWER_STEPS -end - --- Colour for antenna cell n (1 or 2): lit when that path is the active one. --- A factory, not a callback -- call it when building. function Display.antColor(n) @@ -442,23 +427,6 @@ function Display.detailColor() return Display.healthTextColor(level) end ---- Hero label font for one tier of a screen's WidgetUI.fonts table. ---- Status text ("No CRSF module") is far longer than "LQ 100%", so tiers that ---- would overflow declare a smaller heroStatus and drop to it while a status ---- shows. Tiers without one get the constant back, so no callback runs per ---- frame. This is a factory, not a callback: call it when building. -function Display.heroFont(tier) - if not tier.heroStatus then - return tier.hero - end - return function() - if Display.statusText() then - return tier.heroStatus - end - return tier.hero - end -end - -- ============================================================================ -- Return components -- ============================================================================ diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 0c6b750..0b59675 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -22,55 +22,76 @@ end -- Breakpoints: absolute pixel values for 800x480. WidgetUI.breakpoints = { - wideW = 560, topBarW = 200, quarterH = 104, thirdH = 146, halfH = 154, } +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 209px half up to the 418px full, and the builder takes the largest +-- font the zone's height budget affords. This screen's fonts are large enough +-- that BOLD never earns a rung. WidgetUI.fonts = { compact = { hero = SMLSIZE }, - third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, heroStatus = STDSIZE, detail = 0 }, + third = { hero = MIDSIZE }, + half = { hero = MIDSIZE }, + full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE } }, } +-- The card's corner radius and the cap on bar thickness, in this screen's +-- pixels. +local ROUNDED = 8 +local BAR_H = 8 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/4 and 1/6: one row of readings over the headroom bar. The composition ---- is shared, and it sheds cells and bar height as the zone shrinks. +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. +--- 1/3: header, the hero beside the RSSI pair, one bar. function WidgetUI.buildThird(w, h, opa) local m = measured() - Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.mid }) + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/2: the panel without the group rows or the rules. +--- 1/2: the card without the full grid. --- Both bars survive, which is the whole point of the layout: this is the --- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) local m = measured() - Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/1: the uplink panel between a status strip and the group rows. +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. --- Everything but the fonts is shared, so the composition itself lives in ---- ui/components.lua and this picks the hero size the tier can afford. +--- ui/components.lua and this hands it the ladder the screen can afford. function WidgetUI.buildFull(w, h, opa) local m = measured() Components.fullTier(w, h, opa, m, { - wide = w >= WidgetUI.breakpoints.wideW, - lqFont = MIDSIZE, - lqH = m.mid, + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index 069248c..e5bcc70 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -21,47 +21,65 @@ local function measured() end -- Breakpoints: absolute pixel values for 320x480 portrait. +-- No half rung: portrait zones are tall, so everything past the third +-- breakpoint has the height for the whole card and goes straight to full. WidgetUI.breakpoints = { - wideW = 240, topBarW = 80, quarterH = 78, thirdH = 110, } +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 145px third up to the 435px full. The ladder tops out at DBLSIZE: +-- the screen is narrow, and XXL digits crowd the caption and the readings +-- beside them off a 320-wide card. WidgetUI.fonts = { compact = { hero = SMLSIZE }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, + third = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, } +-- The card's corner radius and the cap on bar thickness, in this screen's +-- pixels. +local ROUNDED = 4 +local BAR_H = 5 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/4 and 1/6: one row of readings over the headroom bar. The composition ---- is shared, and it sheds cells and bar height as the zone shrinks. +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. +--- 1/3: header, the hero beside the RSSI pair, one bar. function WidgetUI.buildThird(w, h, opa) local m = measured() - Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/1: the uplink panel between a status strip and the group rows. +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. --- Everything but the fonts is shared, so the composition itself lives in ---- ui/components.lua and this picks the hero size the tier can afford. +--- ui/components.lua and this hands it the ladder the screen can afford. function WidgetUI.buildFull(w, h, opa) local m = measured() Components.fullTier(w, h, opa, m, { - wide = w >= WidgetUI.breakpoints.wideW, - lqFont = BOLD, - lqH = m.bold, + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index 686bbb1..a9f50c4 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -21,58 +21,76 @@ local function measured() end -- Breakpoints: absolute pixel values for 480x272. --- wideW is the only width gate past the top bar: below it a zone is half the --- screen and the 1/1 layout drops to one column of groups. WidgetUI.breakpoints = { topBarW = 100, quarterH = 52, thirdH = 73, halfH = 108, - wideW = 340, } +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 113px half up to the 227px full, and the builder takes the largest +-- font the zone's height budget affords. WidgetUI.fonts = { compact = { hero = SMLSIZE }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE, BOLD } }, } +-- The card's corner radius and the cap on bar thickness, in this screen's +-- pixels. +local ROUNDED = 6 +local BAR_H = 6 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/4 and 1/6: one row of readings over the headroom bar. The composition ---- is shared, and it sheds cells and bar height as the zone shrinks. +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. +--- 1/3: header, the hero beside the RSSI pair, one bar. function WidgetUI.buildThird(w, h, opa) local m = measured() - Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/2: the panel without the group rows or the rules. +--- 1/2: the card without the full grid. --- Both bars survive, which is the whole point of the layout: this is the --- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) local m = measured() - Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/1: the uplink panel between a status strip and the group rows. +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. --- Everything but the fonts is shared, so the composition itself lives in ---- ui/components.lua and this picks the hero size the tier can afford. +--- ui/components.lua and this hands it the ladder the screen can afford. function WidgetUI.buildFull(w, h, opa) local m = measured() Components.fullTier(w, h, opa, m, { - wide = w >= WidgetUI.breakpoints.wideW, - lqFont = MIDSIZE, - lqH = m.mid, + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 571b2f8..2be454b 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -23,55 +23,75 @@ end -- Breakpoints: absolute pixel values for 480x320. -- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { - wideW = 340, topBarW = 100, quarterH = 62, thirdH = 86, halfH = 108, } +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 137px half up to the 275px full, and the builder takes the largest +-- font the zone's height budget affords. WidgetUI.fonts = { compact = { hero = SMLSIZE }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE, BOLD } }, } +-- The card's corner radius and the cap on bar thickness, in this screen's +-- pixels. +local ROUNDED = 6 +local BAR_H = 6 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/4 and 1/6: one row of readings over the headroom bar. The composition ---- is shared, and it sheds cells and bar height as the zone shrinks. +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. +--- 1/3: header, the hero beside the RSSI pair, one bar. function WidgetUI.buildThird(w, h, opa) local m = measured() - Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/2: the panel without the group rows or the rules. +--- 1/2: the card without the full grid. --- Both bars survive, which is the whole point of the layout: this is the --- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) local m = measured() - Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/1: the uplink panel between a status strip and the group rows. +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. --- Everything but the fonts is shared, so the composition itself lives in ---- ui/components.lua and this picks the hero size the tier can afford. +--- ui/components.lua and this hands it the ladder the screen can afford. function WidgetUI.buildFull(w, h, opa) local m = measured() Components.fullTier(w, h, opa, m, { - wide = w >= WidgetUI.breakpoints.wideW, - lqFont = MIDSIZE, - lqH = m.mid, + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index 5671aac..ec8ead6 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -23,55 +23,75 @@ end -- Breakpoints: absolute pixel values for 320x240. -- Smallest color screen — everything is compact. WidgetUI.breakpoints = { - wideW = 240, topBarW = 80, quarterH = 42, thirdH = 58, halfH = 82, } +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 102px half up to the 204px full. The ladder tops out at MIDSIZE: +-- the display faces beyond it swallow this screen's card whole. WidgetUI.fonts = { compact = { hero = SMLSIZE }, - third = { hero = BOLD, detail = SMLSIZE }, - full = { hero = BOLD, detail = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { MIDSIZE, BOLD } }, } +-- The card's corner radius and the cap on bar thickness, in this screen's +-- pixels. +local ROUNDED = 4 +local BAR_H = 5 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) ---- 1/4 and 1/6: one row of readings over the headroom bar. The composition ---- is shared, and it sheds cells and bar height as the zone shrinks. +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. function WidgetUI.buildCompact(w, h, opa) local m = measured() - Components.compactTier(w, h, opa, m, { lqFont = WidgetUI.fonts.compact.hero, lqH = m.sml }) + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/3: the strip, LQ beside the RSSI pair, then the headroom bar. +--- 1/3: header, the hero beside the RSSI pair, one bar. function WidgetUI.buildThird(w, h, opa) local m = measured() - Components.thirdTier(w, h, opa, m, { lqFont = WidgetUI.fonts.third.hero, lqH = m.bold }) + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/2: the panel without the group rows or the rules. +--- 1/2: the card without the full grid. --- Both bars survive, which is the whole point of the layout: this is the --- size the widget is usually placed at. function WidgetUI.buildHalf(w, h, opa) local m = measured() - Components.halfTier(w, h, opa, m, { lqFont = BOLD, lqH = m.bold }) + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + rounded = ROUNDED, + }) end ---- 1/1: the uplink panel between a status strip and the group rows. +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. --- Everything but the fonts is shared, so the composition itself lives in ---- ui/components.lua and this picks the hero size the tier can afford. +--- ui/components.lua and this hands it the ladder the screen can afford. function WidgetUI.buildFull(w, h, opa) local m = measured() Components.fullTier(w, h, opa, m, { - wide = w >= WidgetUI.breakpoints.wideW, - lqFont = BOLD, - lqH = m.bold, + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index 63d4145..88931da 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -5,7 +5,8 @@ -- -- -- VTXDisplay is the read-model the per-screen ui/ files consume: -- -- zero-arg formatters passed by reference as LVGL text/color/visible -- --- callbacks. WidgetLayout builds the minimized zone containers. -- +-- callbacks, plus the card's composite rows. WidgetLayout builds the -- +-- minimized zone containers. -- --------------------------------------------------------------------------- local VTXAdmin, PresetsStorage = ... @@ -20,7 +21,9 @@ local WidgetLayout = {} --- costs more height than it has. Pass a table to keep the horizontal padding --- while trimming the vertical: the left edge is what a widget in the zone --- above or beside lines its own text up against, so it is not the one to give. -function WidgetLayout.column(w, h, opa, children, pad) +--- rounded is the card's corner radius, on the fill alone: the box draws +--- nothing, so its square corners cannot show. +function WidgetLayout.column(w, h, opa, children, pad, rounded) lvgl.build({ { type = lvgl.RECTANGLE, @@ -31,6 +34,7 @@ function WidgetLayout.column(w, h, opa, children, pad) color = COLOR_THEME_PRIMARY2, opacity = opa, filled = true, + rounded = rounded, }, { type = lvgl.BOX, @@ -47,7 +51,7 @@ function WidgetLayout.column(w, h, opa, children, pad) }) end -function WidgetLayout.row(w, h, opa, children) +function WidgetLayout.row(w, h, opa, children, rounded) lvgl.build({ { type = lvgl.RECTANGLE, @@ -58,6 +62,7 @@ function WidgetLayout.row(w, h, opa, children) color = COLOR_THEME_PRIMARY2, opacity = opa, filled = true, + rounded = rounded, }, { type = lvgl.BOX, @@ -112,143 +117,219 @@ function VTXDisplay.statusText() return "" end -function VTXDisplay.powerShort() - if not VTXAdmin.hasPower() then - return "" - end - return table.concat({ "P", VTXAdmin.state.power }) -end - -function VTXDisplay.powerLong() - if not VTXAdmin.hasPower() then - return "" +--- The hero and the active preset cell take the theme's accent -- the reading +--- is what the widget exists to show, and the accent is what "the current one" +--- looks like everywhere else in EdgeTX. A confirmed pit mode outranks it: +--- red on the reading itself is the one signal worth recolouring the hero for. +--- An aux binding is not an assertion, so it stays on the accent. +function VTXDisplay.heroColor() + if VTXAdmin.state.pitmode then + return RED end - return table.concat({ "Power ", VTXAdmin.state.power }) + return COLOR_THEME_FOCUS end ---- Pit mode state. "" when no power is set: ExpressLRS cannot send pit mode without it. ---- A switch binding names the switch rather than asserting a position the folder name ---- does not carry. -function VTXDisplay.pitText() +--- Pit mode as the header's right end spells it. "" when no power is set: +--- ExpressLRS cannot send pit mode without it, and the firmware hides the +--- field. A switch binding names the switch rather than asserting a position +--- the folder name does not carry. +function VTXDisplay.pitHeaderText() if not VTXAdmin.hasPower() then return "" end if VTXAdmin.state.pitmode then - return "Pit Mode On" + return "PIT ON" end if VTXAdmin.state.pitmodeAux then - return table.concat({ "Pit Mode ", VTXAdmin.state.pitmodeAux }) - end - return "Pit Mode Off" -end - ---- As pitText, but reports a disabled VTX instead of falling silent. -function VTXDisplay.pitTextLong() - if VTXAdmin.isDisabled() then - return "VTX Disabled" + return table.concat({ "PIT ", VTXAdmin.state.pitmodeAux }) end - return VTXDisplay.pitText() + return "PIT OFF" end ---- Terse flag for narrow tiers. Only a confirmed pit mode is worth the width. -function VTXDisplay.pitShort() - return VTXAdmin.state.pitmode and "Pit" or "" -end - ---- Red only when pit mode is confirmed on. An aux binding is not an assertion. -function VTXDisplay.pitColor() +--- Red only when pit mode is confirmed on. +function VTXDisplay.pitHeaderColor() return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1 end -function VTXDisplay.detailLine() - if not VTXAdmin.hasPower() then - return "" - end - return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() }) -end - -function VTXDisplay.detailLong() - if VTXAdmin.isDisabled() then - return "VTX Disabled" - end - if not VTXAdmin.hasPower() then - return "" - end - return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() }) +--- The power level on its own, for the power row's right end. +function VTXDisplay.powerValueText() + return tostring(VTXAdmin.state.power) end ---- Whether detailLong() has anything to say. On the tiers that give it a column ---- row of its own an empty label still costs a full line, and that line is the ---- gap that opens between the band and the cheatsheet on a VTX reporting no ---- power. A flex column drops hidden children rather than reserving their space, ---- so this closes the gap instead of merely blanking it. -function VTXDisplay.hasDetail() - return VTXAdmin.isDisabled() or VTXAdmin.hasPower() +--- The header row: the widget's name in the muted caption colour on the left, +--- the pit state pinned to the right edge of the same line. +--- Not a flex row: a right-aligned label needs an explicit width to align +--- inside, so the two labels sit in a plain box at absolute x and the hidden +--- pit label cannot reflow the name. +--- w is the zone width; the box subtracts the container's own padding so the +--- right edge lands where every other row's content ends. +function VTXDisplay.buildHeader(w) + local cw = w - 2 * lvgl.PAD_SMALL + return { + type = lvgl.BOX, + w = cw, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + x = 0, + y = 0, + w = cw, + align = RIGHT, + font = SMLSIZE, + color = VTXDisplay.pitHeaderColor, + text = VTXDisplay.pitHeaderText, + visible = VTXDisplay.showChannel, + }, + }, + } end -function VTXDisplay.mainColor() - if VTXAdmin.state.pitmode then - return RED - end - return COLOR_THEME_PRIMARY1 +--- The hero pair for a flex column: the status while the VTX is quiet, the +--- band and channel in the tier's display font once it is tuned. Two children +--- to append in order -- a hidden flex child costs no height, so only the live +--- one takes a row. +function VTXDisplay.buildHero(font) + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } end -function VTXDisplay.build6posLabels(font) +--- The 6POS presets as a row of six equal cells, the latched one lit in the +--- accent. The row is always exactly six cells and never reflows: a preset +--- without a band shows "--" in its cell rather than giving the cell up, and +--- the position is the cell's place in the row, so the labels drop the "n:" +--- prefix the text cheatsheet needed. +--- The active cell recolours through closures -- the cells themselves never +--- move or resize, which keeps the row to zero layout work per frame. +--- spec.w is the width the row may span; spec.cellH, spec.font and +--- spec.rounded come from the screen file's own scale. +--- Returns nil when the presets feature is off or no module is present, the +--- same build-time gates the text cheatsheet had; the runtime module gate +--- rides the row's visible. +function VTXDisplay.buildCells(spec) if not VTXAdmin.hasModule() then - return {} + return nil end if not PresetsStorage.enabled then - return {} + return nil end - local labels = {} + -- The mock's own cell gap. Tighter than the theme paddings, and fixed: six + -- cells only read as one control when the gaps between them are beats, not + -- breaks. + local gap = 4 + local cellW = math.floor((spec.w - 5 * gap) / 6) + local font = spec.font or SMLSIZE + local textY = math.floor((spec.cellH - select(2, lcd.sizeText("0", font))) / 2) + local cells = {} for i = 1, 6 do local idx = i - labels[#labels + 1] = { - type = lvgl.LABEL, - font = font or SMLSIZE, + cells[#cells + 1] = { + type = lvgl.RECTANGLE, + w = cellW, + h = spec.cellH, + filled = true, + rounded = spec.rounded, color = function() - return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED + return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_FOCUS or COLOR_THEME_DISABLED end, - text = function() - local p = PresetsStorage.items[idx] - if p.band == 0 then - return table.concat({ idx, ":--" }) - end - return table.concat({ idx, ":", VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + -- The inactive cells sit back at part opacity so the lit one carries + -- the row; opacity is background-only on a rectangle, so the label + -- inside keeps its full weight either way. + opacity = function() + return (PresetsStorage.latch.lastPos == idx) and 255 or 90 end, + children = { + { + type = lvgl.LABEL, + x = 0, + y = math.max(0, textY), + w = cellW, + align = CENTER, + font = font, + color = function() + return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_PRIMARY2 or COLOR_THEME_SECONDARY1 + end, + text = function() + local p = PresetsStorage.items[idx] + if p.band == 0 then + return "--" + end + return table.concat({ VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + end, + }, + }, } end - return labels -end - ---- Wrap 6POS labels in a row box. Shared by the one-row and two-row cheatsheets. -local function cheatsheetRow(labels) return { type = lvgl.BOX, flexFlow = lvgl.FLOW_ROW, borderPad = 0, - flexPad = lvgl.PAD_TINY, + flexPad = gap, align = LEFT, visible = VTXAdmin.hasModule, - children = labels, + children = cells, } end ---- Single row of all six presets, for tiers with only one line to spare. -function VTXDisplay.buildCheatsheet(font) - local labels = VTXDisplay.build6posLabels(font) - if #labels == 0 then - return nil - end - return cheatsheetRow(labels) +--- The power row at the card's foot: the caption on the left, the level +--- right-aligned. Same box shape as the header, and hidden whole -- a flex +--- column drops a hidden child rather than reserving its line, so a VTX +--- reporting no power closes the row instead of leaving a gap. +function VTXDisplay.buildPowerRow(w) + local cw = w - 2 * lvgl.PAD_SMALL + return { + type = lvgl.BOX, + w = cw, + visible = VTXAdmin.hasPower, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "Power", + }, + { + type = lvgl.LABEL, + x = 0, + y = 0, + w = cw, + align = RIGHT, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.powerValueText, + }, + }, + } end ---- The headline row: the widget's name, then the band and channel, on one line at ---- one size. For the tiers with no row to spare for a title of its own. +--- The headline row: the widget's name, then the band and channel, on one line +--- at one size. For the tiers with no row to spare for a title of its own. --- Both at one size on purpose. The name is a caption in the muted theme colour ---- and the reading is the primary, which is what lets them share a line without ---- competing -- where two type sizes in one breath read as two importances, and ---- a name shrunk beside its own value reads as an afterthought. +--- and the reading takes the accent, which is what lets them share a line +--- without competing -- where two type sizes in one breath read as two +--- importances, and a name shrunk beside its own value reads as an afterthought. --- extras are trailing labels, for a tier with nowhere else to put them. function VTXDisplay.buildHeadline(w, font, extras) local children = { @@ -263,7 +344,7 @@ function VTXDisplay.buildHeadline(w, font, extras) type = lvgl.LABEL, align = LEFT, font = font, - color = VTXDisplay.mainColor, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, @@ -285,90 +366,4 @@ function VTXDisplay.buildHeadline(w, font, extras) } end ---- The power and pit-mode labels that ride a headline row, in the form the width ---- affords: pit mode said in full where there is room for it, and terse -- "Pit" ---- rather than "Pit Mode Off" -- where the name and the reading come first and ---- the row cannot hold all three whole. ---- Two labels either way, because pit mode carries its own colour: a confirmed ---- pit mode is the one thing on this row worth going red, and a single label ---- holding both readings could only be one colour. ---- A caller with no spare row for the status appends its own status label after ---- these. -function VTXDisplay.buildDetailExtras(wide) - return { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = wide and VTXDisplay.pitText or VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - } -end - ---- The rows 1/2 and 1/1 share: the name, the band and channel as a hero on its ---- own line, and the detail line. The caller appends its cheatsheet rows. ---- detailText overrides the detail formatter, for a screen too narrow for the ---- full one. Its emptiness follows hasDetail either way, since every form of the ---- line falls silent on the same two conditions. -function VTXDisplay.buildHeroRows(fonts, detailText) - return { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = fonts.hero, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = fonts.detail, - color = COLOR_THEME_SECONDARY1, - text = detailText or VTXDisplay.detailLong, - visible = VTXDisplay.hasDetail, - }, - } -end - ---- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows. -function VTXDisplay.buildCheatsheetRows(font) - local labels = VTXDisplay.build6posLabels(font) - if #labels == 0 then - return nil, nil - end - local row1, row2 = {}, {} - for i = 1, 3 do - row1[#row1 + 1] = labels[i] - end - for i = 4, 6 do - row2[#row2 + 1] = labels[i] - end - return cheatsheetRow(row1), cheatsheetRow(row2) -end - return VTXDisplay, WidgetLayout diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 9dc5b1d..9d3e1cc 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -11,38 +11,37 @@ local WidgetLayout = ctx.WidgetLayout local WidgetUI = {} -- Breakpoints: absolute pixel values for 800x480. --- Reference zone heights (no deco → with deco): --- 1/6: 69→~58 1/4: 104→~87 1/3: 139→116 1/2: 209→175 3/4: 313→~262 --- Thresholds must work for both decorated and undecorated layouts. WidgetUI.breakpoints = { topBarW = 200, sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) - -- The 1/6 tier stacks the cheatsheet on a second row from here up, - -- and only where the six columns have the width for it. + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. sixthStackH = 64, sixthStackW = 300, quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) - -- From here up a zone spans the screen rather than half of it. What it buys - -- differs by tier: the whole cheatsheet on one row and a step up in type at - -- 1/3, pit mode said in full at 1/6. + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. wideW = 560, } +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD }, - quarter = { status = BOLD }, - third = { status = MIDSIZE, cheatsheet = STDSIZE }, - -- Two lines at the half-width sizes fill the top half of a full-width zone - -- and leave the rest of the panel empty. The band is already as large as the - -- 1/2 tier's, so it is the cheatsheet that steps up -- and this is the one - -- tier where six presets at that size still have the width to sit on one row. - thirdWide = { status = MIDSIZE, cheatsheet = MIDSIZE }, - half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = DBLSIZE, detail = STDSIZE, cheatsheet = STDSIZE }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 18 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 24 }, + third = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 27 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 30 }, } +-- The card's corner radius, and the smaller one on the preset cells, in this +-- screen's pixels. +local ROUNDED = 8 +local CELL_ROUNDED = 6 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -51,176 +50,124 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. ---- Two rows are what make room for the name: with the whole cheatsheet on the row ---- below, the one above is left to the name, the reading and the detail, and none ---- of the three has to give. That is worth having at any width -- a full-width ---- zone can fit all four inline, but then the widget is a row of readings with ---- nothing saying whose they are, where every other tier says "VTX Admin". ---- It fits at all only on the container's padding: at this tier PAD_SMALL top and ---- bottom is the whole difference between two rows and one. So the vertical ---- padding gives and the left edge keeps it -- that edge is what a widget in the ---- zone above lines its own text up against. ---- A zone too short for two rows, or too narrow to carry six cells on one, falls ---- back to a single inline row, which has no width to spare for the name. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier, spanning the container's content +--- width. nil when the presets feature is off, same as the builder. +local function cells(f, w) + return VTXDisplay.buildCells({ + w = w - 2 * lvgl.PAD_SMALL, + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) +end + +--- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, +--- the preset cells, the power row at the foot. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row, which has no width to spare for the +--- name. Fixed-width band column prevents layout jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints - local cheatsheet = nil + local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet then - local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) - extras[#extras + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) - return + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + bottom = lvgl.PAD_TINY, + }, ROUNDED) + return + end end local c1w = math.floor(w * 0.22) local columns = { - { - type = lvgl.LABEL, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + statusLabel(), { type = lvgl.LABEL, w = c1w, - font = WidgetUI.fonts.sixth.status, - color = VTXDisplay.mainColor, + font = f.status, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, - { - type = lvgl.LABEL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - }, } if w >= bp.wideW then - local labels = VTXDisplay.build6posLabels() - for _, lbl in ipairs(labels) do - columns[#columns + 1] = lbl - end + columns[#columns + 1] = VTXDisplay.buildCells({ + w = math.floor(w * 0.55), + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) end - WidgetLayout.row(w, h, opa, columns) + WidgetLayout.row(w, h, opa, columns, ROUNDED) end ---- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. ---- Power rides the headline rather than taking a row: two rows is all there is ---- here, and the cheatsheet has the other one. ---- The status line rides it too, which is the one place it does. This tier has no ---- spare row to hold it, so "VTX Admin Loading..." is the whole first line while ---- the VTX is quiet. +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. function WidgetUI.buildQuarter(w, h, opa) - local font = WidgetUI.fonts.quarter.status - local extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = font, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - local rows = { VTXDisplay.buildHeadline(w, font, extras) } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet - end - WidgetLayout.column(w, h, opa, rows) + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/3: the headline, then the cheatsheet. ---- The name shares the headline with the reading rather than taking a line of ---- its own, as it does at 1/4: at one size, told apart by colour, they read as a ---- caption and its value. The line that buys puts the whole cheatsheet on one ---- row, which is what a full-width zone has the width for -- two rows of three ---- across the full 800 leave most of the widget empty. ---- A half-width zone keeps the two rows, the terse detail forms and the smaller ---- cheatsheet. +--- 1/3: the header with the pit state, the hero on its own line, the cells. +--- The power row is what gives at this height; a confirmed pit mode still +--- shows, in the header and on the hero's colour. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.wideW - local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local rows = { - VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), - -- The status keeps a row of its own here, unlike at 1/4. This tier has the - -- height for it, and a hidden flex child costs nothing while the VTX is - -- tuned. - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - } - if wide then - local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) - if cs then - rows[#rows + 1] = cs - end - else - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - end - - WidgetLayout.column(w, h, opa, rows) + local f = WidgetUI.fonts.third + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/2: title + MIDSIZE band + detail + cheatsheet. +--- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - local f = WidgetUI.fonts.half - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) end ---- 1/1: title + DBLSIZE band + detail + cheatsheet. +--- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - local f = WidgetUI.fonts.full - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index 1c29400..cf34d59 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -1,10 +1,9 @@ --------------------------------------------------------------------------- -- VTX Administrator Widget - UI for 320x480 (Portrait) -- --- FlySky EL18 — vertical screen, widget zones differ significantly -- +-- FlySky EL18 — vertical screen -- --------------------------------------------------------------------------- local ctx = ... -local VTXAdmin = ctx.VTXAdmin local bgOpacity = ctx.bgOpacity local VTXDisplay = ctx.VTXDisplay local WidgetLayout = ctx.WidgetLayout @@ -12,53 +11,34 @@ local WidgetLayout = ctx.WidgetLayout local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x480 portrait. --- Portrait widget zones tend to be wider-relative-to-height than landscape. --- Height tiers are scaled for the taller 480px screen. +-- Portrait zones are tall for their width, so every rung starts higher than +-- on the landscape screens. No stacked 1/6 variant: below sixthH the zone is +-- a custom sliver with one row in it. WidgetUI.breakpoints = { topBarW = 80, sixthH = 70, quarterH = 100, thirdH = 140, halfH = 210, - -- From here up a zone spans the screen rather than half of it: at 1/3 the whole - -- cheatsheet goes on one row and the band steps up to the size that width - -- affords. 320px is all there is, so the cheatsheet cannot follow it. + -- Past the top bar this only gates the 1/6 inline row's preset cells; the + -- screen itself is 320 wide, so a full-width zone clears it. wideW = 240, } +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD }, - quarter = { status = BOLD }, - third = { status = BOLD, cheatsheet = STDSIZE }, - thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, - half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 12 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 14 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 14 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 16 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 18 }, } --- ============================================================================ --- Minimized display helpers (portrait-specific overrides) --- ============================================================================ - ---- Shorter detail line for narrow portrait screen. -local function detailLine() - if not VTXAdmin.hasPower() then - return "" - end - local pit = VTXAdmin.state.pitmode and " Pit" or "" - return table.concat({ VTXDisplay.powerShort(), pit }) -end - ---- Shorter long-form detail line for narrow portrait screen. -local function detailLong() - if VTXAdmin.isDisabled() then - return "VTX Disabled" - end - if not VTXAdmin.hasPower() then - return "" - end - local pit = VTXAdmin.state.pitmode and " Pit" or "" - return table.concat({ VTXDisplay.powerLong(), pit }) -end +-- The card's corner radius, and the smaller one on the preset cells, in this +-- screen's pixels. +local ROUNDED = 4 +local CELL_ROUNDED = 3 -- ============================================================================ -- Minimized layout builders (by widget height tier) @@ -68,183 +48,102 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: single row with band/channel + compact detail. +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier, spanning the container's content +--- width. nil when the presets feature is off, same as the builder. +local function cells(f, w) + return VTXDisplay.buildCells({ + w = w - 2 * lvgl.PAD_SMALL, + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) +end + +--- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, +--- the preset cells, the power row at the foot. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + return rows +end + +--- 1/6: a single inline row -- the status or the band and channel, then the +--- preset cells where the width allows. No stacked variant here; the name is +--- what this tier gives up. --- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) - local c1w = math.floor(w * 0.28) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + local c1w = math.floor(w * 0.22) local columns = { + statusLabel(), { type = lvgl.LABEL, w = c1w, - font = WidgetUI.fonts.sixth.status, - color = VTXDisplay.mainColor, + font = f.status, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, - { - type = lvgl.LABEL, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.LABEL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = detailLine, - }, } - local labels = VTXDisplay.build6posLabels() - for _, lbl in ipairs(labels) do - columns[#columns + 1] = lbl + if w >= bp.wideW then + columns[#columns + 1] = VTXDisplay.buildCells({ + w = math.floor(w * 0.55), + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) end - WidgetLayout.row(w, h, opa, columns) + WidgetLayout.row(w, h, opa, columns, ROUNDED) end ---- 1/4: title + band/channel + power + cheatsheet. ---- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. function WidgetUI.buildQuarter(w, h, opa) - local c1w = math.floor(w * 0.28) + local f = WidgetUI.fonts.quarter local rows = { - { - type = lvgl.LABEL, - font = BOLD, - color = COLOR_THEME_SECONDARY1, - text = "VTX Admin", - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - { - type = lvgl.BOX, - w = w, - align = LEFT + VCENTER, - flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - borderPad = 0, - visible = VTXDisplay.showChannel, - children = { - { - type = lvgl.LABEL, - w = c1w, - align = LEFT, - font = WidgetUI.fonts.quarter.status, - color = VTXDisplay.mainColor, - text = VTXDisplay.bandChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitText, - }, - }, - }, + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - if w < 200 then - local cs1, cs2 = VTXDisplay.buildCheatsheetRows() - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - else - local cs = VTXDisplay.buildCheatsheet() - if cs then - rows[#rows + 1] = cs - end - end - - WidgetLayout.column(w, h, opa, rows) + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/3: the headline, then the cheatsheet. ---- The name shares the headline with the reading rather than taking a line of ---- its own, as it does at 1/4: at one size, told apart by colour, they read as a ---- caption and its value. The line that buys puts the whole cheatsheet on one ---- row where the zone spans the screen; a half-width zone keeps the two rows. +--- 1/3: the header with the pit state, the hero on its own line, the cells. +--- The power row is what gives at this height; a confirmed pit mode still +--- shows, in the header and on the hero's colour. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.wideW - local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - -- Already the short form on this screen, so there is nothing terser to - -- fall back to on a narrow zone. - text = detailLine, - visible = VTXDisplay.showChannel, - }, - } - local rows = { - VTXDisplay.buildHeadline(w, f.status, extras), - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - } - if wide then - local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) - if cs then - rows[#rows + 1] = cs - end - else - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - end - - WidgetLayout.column(w, h, opa, rows) + local f = WidgetUI.fonts.third + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/2: title + band/channel + detail + cheatsheet. +--- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - local f = WidgetUI.fonts.half - local rows = VTXDisplay.buildHeroRows(f, detailLong) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) end ---- 1/1: title + MIDSIZE band/channel + detail + cheatsheet. +--- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - local f = WidgetUI.fonts.full - local rows = VTXDisplay.buildHeroRows(f, detailLong) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 4714d53..9d75c6a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -14,33 +14,34 @@ local WidgetUI = {} WidgetUI.breakpoints = { topBarW = 100, sixthH = 50, - -- The 1/6 tier stacks the cheatsheet on a second row from here up, - -- and only where the six columns have the width for it. + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. sixthStackH = 40, sixthStackW = 180, quarterH = 70, thirdH = 100, halfH = 125, - -- From here up a zone spans the screen rather than half of it. A half-screen - -- zone measures ~232 here and the full width of the placed layouts ~396, so - -- this separates the two with room either side. What it buys differs by tier: - -- the whole cheatsheet on one row and a step up in type at 1/3, pit mode said - -- in full at 1/6. + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. wideW = 340, } +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD }, - quarter = { status = BOLD }, - third = { status = BOLD, cheatsheet = SMLSIZE }, - -- A full-width 1/3 zone is 400px across and 80 tall for two lines. At the - -- half-width sizes those two lines fill its top third and leave the rest of - -- the panel empty, so both of them step up. - thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, - half = { hero = BOLD, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 14 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 18 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, } +-- The card's corner radius, and the smaller one on the preset cells, in this +-- screen's pixels. +local ROUNDED = 6 +local CELL_ROUNDED = 4 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -49,178 +50,124 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. ---- Two rows are what make room for the name: with the whole cheatsheet on the row ---- below, the one above is left to the name, the reading and the detail, and none ---- of the three has to give. That is worth having at any width -- a full-width ---- zone can fit all four inline, but then the widget is a row of readings with ---- nothing saying whose they are, where every other tier says "VTX Admin". ---- It fits at all only on the container's padding: at this tier PAD_SMALL top and ---- bottom is the whole difference between two rows and one. So the vertical ---- padding gives and the left edge keeps it -- that edge is what a widget in the ---- zone above lines its own text up against. ---- A zone too short for two rows, or too narrow to carry six cells on one, falls ---- back to a single inline row, which has no width to spare for the name. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier, spanning the container's content +--- width. nil when the presets feature is off, same as the builder. +local function cells(f, w) + return VTXDisplay.buildCells({ + w = w - 2 * lvgl.PAD_SMALL, + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) +end + +--- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, +--- the preset cells, the power row at the foot. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row, which has no width to spare for the +--- name. Fixed-width band column prevents layout jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints - local cheatsheet = nil + local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet then - local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) - extras[#extras + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) - return + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + bottom = lvgl.PAD_TINY, + }, ROUNDED) + return + end end local c1w = math.floor(w * 0.22) local columns = { - { - type = lvgl.LABEL, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + statusLabel(), { type = lvgl.LABEL, w = c1w, - font = WidgetUI.fonts.sixth.status, - color = VTXDisplay.mainColor, + font = f.status, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, - { - type = lvgl.LABEL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - }, } if w >= bp.wideW then - local labels = VTXDisplay.build6posLabels() - for _, lbl in ipairs(labels) do - columns[#columns + 1] = lbl - end + columns[#columns + 1] = VTXDisplay.buildCells({ + w = math.floor(w * 0.55), + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) end - WidgetLayout.row(w, h, opa, columns) + WidgetLayout.row(w, h, opa, columns, ROUNDED) end ---- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. ---- Power rides the headline rather than taking a row: two rows is all there is ---- here, and the cheatsheet has the other one. ---- The status line rides it too, which is the one place it does. This tier has no ---- spare row to hold it, so "VTX Admin Loading..." is the whole first line while ---- the VTX is quiet -- and that is why the fit below is measured against the ---- status string as well as the band. +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. function WidgetUI.buildQuarter(w, h, opa) - local font = WidgetUI.fonts.quarter.status + local f = WidgetUI.fonts.quarter local rows = { - VTXDisplay.buildHeadline(w, font, { - { - type = lvgl.LABEL, - align = LEFT, - font = font, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - }), + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet - end - WidgetLayout.column(w, h, opa, rows) + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/3: the headline, then the cheatsheet. ---- The name shares the headline with the reading rather than taking a line of ---- its own, as it does at 1/4: at one size, told apart by colour, they read as a ---- caption and its value. The line that buys puts the whole cheatsheet on one ---- row, which is what a full-width zone has the width for -- two rows of three ---- across 396px leave the right half of the widget empty. ---- A half-width zone has neither the width for six columns nor for the detail ---- line in full, so it keeps the two rows, the terse forms and the smaller type. +--- 1/3: the header with the pit state, the hero on its own line, the cells. +--- The power row is what gives at this height; a confirmed pit mode still +--- shows, in the header and on the hero's colour. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.wideW - local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local rows = { - VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), - -- The status keeps a row of its own here, unlike at 1/4. This tier has the - -- height for it, and a hidden flex child costs nothing while the VTX is - -- tuned. - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - } - if wide then - local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) - if cs then - rows[#rows + 1] = cs - end - else - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - end - - WidgetLayout.column(w, h, opa, rows) + local f = WidgetUI.fonts.third + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/2: title + band/status + detail + cheatsheet. +--- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - local f = WidgetUI.fonts.half - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) end ---- 1/1: title + band/status + detail + cheatsheet. +--- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - local f = WidgetUI.fonts.full - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index f3d7b70..8bee7a6 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -11,34 +11,39 @@ local WidgetLayout = ctx.WidgetLayout local WidgetUI = {} -- Breakpoints: absolute pixel values for 480x320. --- 48px taller than 480x272 so widget zones are proportionally taller. WidgetUI.breakpoints = { topBarW = 100, sixthH = 50, - -- The 1/6 tier stacks the cheatsheet on a second row from here up, - -- and only where the six columns have the width for it. + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. sixthStackH = 40, sixthStackW = 180, quarterH = 62, thirdH = 118, halfH = 147, - -- From here up a zone spans the screen rather than half of it. What it buys - -- differs by tier: the whole cheatsheet on one row and a step up in type at - -- 1/3, pit mode said in full at 1/6. + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. wideW = 340, } +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +-- The third tier's hero stays BOLD: its rung starts at 62px here, which is +-- too short for MIDSIZE over a cell row. WidgetUI.fonts = { - sixth = { status = BOLD }, - quarter = { status = BOLD }, - third = { status = BOLD, cheatsheet = SMLSIZE }, - -- Two lines at the half-width sizes fill the top third of a full-width zone - -- and leave the rest of the panel empty, so both of them step up. - thirdWide = { status = MIDSIZE, cheatsheet = STDSIZE }, - half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, - full = { hero = DBLSIZE, detail = SMLSIZE, cheatsheet = STDSIZE }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 14 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 18 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, } +-- The card's corner radius, and the smaller one on the preset cells, in this +-- screen's pixels. +local ROUNDED = 6 +local CELL_ROUNDED = 4 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -47,188 +52,124 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: the headline over the cheatsheet, wherever the zone holds two rows. ---- Two rows are what make room for the name: with the whole cheatsheet on the row ---- below, the one above is left to the name, the reading and the detail, and none ---- of the three has to give. That is worth having at any width -- a full-width ---- zone can fit all four inline, but then the widget is a row of readings with ---- nothing saying whose they are, where every other tier says "VTX Admin". ---- It fits at all only on the container's padding: at this tier PAD_SMALL top and ---- bottom is the whole difference between two rows and one. So the vertical ---- padding gives and the left edge keeps it -- that edge is what a widget in the ---- zone above lines its own text up against. ---- A zone too short for two rows, or too narrow to carry six cells on one, falls ---- back to a single inline row, which has no width to spare for the name. ---- Fixed-width band column prevents layout jumping when values change. ---- Status text (loading/error/off) uses unconstrained label for narrow columns. +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier, spanning the container's content +--- width. nil when the presets feature is off, same as the builder. +local function cells(f, w) + return VTXDisplay.buildCells({ + w = w - 2 * lvgl.PAD_SMALL, + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) +end + +--- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, +--- the preset cells, the power row at the foot. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row, which has no width to spare for the +--- name. Fixed-width band column prevents layout jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints - local cheatsheet = nil + local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - cheatsheet = VTXDisplay.buildCheatsheet() - end - if cheatsheet then - local extras = VTXDisplay.buildDetailExtras(w >= bp.wideW) - extras[#extras + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - } - WidgetLayout.column(w, h, opa, { - VTXDisplay.buildHeadline(w, WidgetUI.fonts.sixth.status, extras), - cheatsheet, - }, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }) - return + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + bottom = lvgl.PAD_TINY, + }, ROUNDED) + return + end end local c1w = math.floor(w * 0.22) local columns = { - { - type = lvgl.LABEL, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + statusLabel(), { type = lvgl.LABEL, w = c1w, - font = WidgetUI.fonts.sixth.status, - color = VTXDisplay.mainColor, + font = f.status, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, - { - type = lvgl.LABEL, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.detailLine, - }, } if w >= bp.wideW then - local labels = VTXDisplay.build6posLabels() - for _, lbl in ipairs(labels) do - columns[#columns + 1] = lbl - end + columns[#columns + 1] = VTXDisplay.buildCells({ + w = math.floor(w * 0.55), + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) end - WidgetLayout.row(w, h, opa, columns) + WidgetLayout.row(w, h, opa, columns, ROUNDED) end ---- 1/4: two rows. Row 1: the headline, power, and pit mode where it fits. ---- Row 2: cheatsheet. ---- Power rides the headline rather than taking a row: two rows is all there is ---- here, and the cheatsheet has the other one. ---- The status line rides it too, which is the one place it does. This tier has no ---- spare row to hold it, so "VTX Admin Loading..." is the whole first line while ---- the VTX is quiet. +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. function WidgetUI.buildQuarter(w, h, opa) - local wide = w > 200 - local font = WidgetUI.fonts.quarter.status - local extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = font, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - } - if wide then - extras[#extras + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitText, - visible = VTXDisplay.showChannel, - } - end - extras[#extras + 1] = { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - local rows = { VTXDisplay.buildHeadline(w, font, extras) } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet - end - WidgetLayout.column(w, h, opa, rows) + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/3: the headline, then the cheatsheet. ---- The name shares the headline with the reading rather than taking a line of ---- its own, as it does at 1/4: at one size, told apart by colour, they read as a ---- caption and its value. The line that buys puts the whole cheatsheet on one ---- row, which is what a full-width zone has the width for -- two rows of three ---- across 396px leave the right half of the widget empty. ---- A half-width zone has neither the width for six columns nor for the detail ---- line in full, so it keeps the two rows, the terse forms and the smaller type. +--- 1/3: the header with the pit state, the hero on its own line, the cells. +--- The power row is what gives at this height; a confirmed pit mode still +--- shows, in the header and on the hero's colour. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.wideW - local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local rows = { - VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), - -- The status keeps a row of its own here, unlike at 1/4. This tier has the - -- height for it, and a hidden flex child costs nothing while the VTX is - -- tuned. - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - } - if wide then - local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) - if cs then - rows[#rows + 1] = cs - end - else - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - end - - WidgetLayout.column(w, h, opa, rows) + local f = WidgetUI.fonts.third + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/2: title + band/status + detail + cheatsheet. +--- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - local f = WidgetUI.fonts.half - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) end ---- 1/1: title + band/status + detail + cheatsheet. +--- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - local f = WidgetUI.fonts.full - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index e131e02..f01d90b 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -11,31 +11,35 @@ local WidgetLayout = ctx.WidgetLayout local WidgetUI = {} -- Breakpoints: absolute pixel values for 320x240. --- Smallest color screen — everything is compact. +-- No stacked 1/6 variant: this screen's 1/6 zones are too short for two rows +-- even on trimmed padding. WidgetUI.breakpoints = { topBarW = 80, sixthH = 38, quarterH = 54, thirdH = 76, halfH = 100, - -- From here up a zone spans the screen rather than half of it: at 1/3 the whole - -- cheatsheet goes on one row and the type steps up as far as 320x240 allows, - -- and pit mode is said in full. + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. wideW = 240, } +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD }, - quarter = { status = BOLD }, - third = { status = BOLD, cheatsheet = SMLSIZE }, - -- Only the cheatsheet steps up. The band stays BOLD because MIDSIZE is what - -- the 1/1 tier uses on this screen, and a 1/3 zone reading larger than the - -- whole-screen one is not a ladder. - thirdWide = { status = BOLD, cheatsheet = STDSIZE }, - half = { hero = BOLD, detail = SMLSIZE, cheatsheet = SMLSIZE }, - full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = SMLSIZE }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 12 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 14 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 14 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 16 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 18 }, } +-- The card's corner radius, and the smaller one on the preset cells, in this +-- screen's pixels. +local ROUNDED = 4 +local CELL_ROUNDED = 3 + -- ============================================================================ -- Minimized layout builders (by widget height tier) -- ============================================================================ @@ -44,154 +48,102 @@ local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ VTXDisplay = VTXDisplay, }) ---- 1/6: single row with band + status + power + pit mode + cheatsheet. +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier, spanning the container's content +--- width. nil when the presets feature is off, same as the builder. +local function cells(f, w) + return VTXDisplay.buildCells({ + w = w - 2 * lvgl.PAD_SMALL, + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) +end + +--- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, +--- the preset cells, the power row at the foot. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + return rows +end + +--- 1/6: a single inline row -- the status or the band and channel, then the +--- preset cells where a full-width zone has the width for them. No stacked +--- variant here; the name is what this screen's 1/6 gives up. --- Fixed-width band column prevents layout jumping when values change. ---- Loading state uses unconstrained label to avoid overflow in narrow columns. function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth local c1w = math.floor(w * 0.22) local columns = { - { - type = lvgl.LABEL, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + statusLabel(), { type = lvgl.LABEL, w = c1w, - font = WidgetUI.fonts.sixth.status, - color = VTXDisplay.mainColor, + font = f.status, + color = VTXDisplay.heroColor, text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitText, - }, } - local labels = VTXDisplay.build6posLabels() - for _, lbl in ipairs(labels) do - columns[#columns + 1] = lbl + if w >= bp.wideW then + columns[#columns + 1] = VTXDisplay.buildCells({ + w = math.floor(w * 0.55), + cellH = f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + }) end - WidgetLayout.row(w, h, opa, columns) + WidgetLayout.row(w, h, opa, columns, ROUNDED) end ---- 1/4: two rows. Row 1: the headline plus power. Row 2: cheatsheet. ---- Power rides the headline rather than taking a row: two rows is all there is ---- here, and the cheatsheet has the other one. ---- The status line rides it too, which is the one place it does. This tier has no ---- spare row to hold it, so "VTX Admin Loading..." is the whole first line while ---- the VTX is quiet. +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. function WidgetUI.buildQuarter(w, h, opa) - local font = WidgetUI.fonts.quarter.status - local extras = { - { - type = lvgl.LABEL, - align = LEFT, - font = font, - color = COLOR_THEME_SECONDARY1, - text = VTXDisplay.powerShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = SMLSIZE, - color = VTXDisplay.pitColor, - text = VTXDisplay.pitShort, - visible = VTXDisplay.showChannel, - }, - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - local rows = { VTXDisplay.buildHeadline(w, font, extras) } - local cheatsheet = VTXDisplay.buildCheatsheet() - if cheatsheet then - rows[#rows + 1] = cheatsheet - end - WidgetLayout.column(w, h, opa, rows) + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/3: the headline, then the cheatsheet. ---- The name costs nothing here because it shares the headline with the reading: ---- at one size, told apart by colour, they read as a caption and its value. That ---- is what gets a title onto 320x240 at all, where a row of its own was always ---- too tight -- and the line it saves puts the whole cheatsheet on one row where ---- the zone is full width. ---- A half-width zone keeps the two rows, the terse detail forms and the smaller ---- cheatsheet. +--- 1/3: the header with the pit state, the hero on its own line, the cells. +--- The power row is what gives at this height; a confirmed pit mode still +--- shows, in the header and on the hero's colour. function WidgetUI.buildThird(w, h, opa) - local wide = w >= WidgetUI.breakpoints.wideW - local f = wide and WidgetUI.fonts.thirdWide or WidgetUI.fonts.third - local rows = { - VTXDisplay.buildHeadline(w, f.status, VTXDisplay.buildDetailExtras(wide)), - { - type = lvgl.LABEL, - align = LEFT, - font = BOLD, - color = VTXDisplay.mainColor, - text = VTXDisplay.statusText, - visible = VTXDisplay.showStatus, - }, - } - if wide then - local cs = VTXDisplay.buildCheatsheet(f.cheatsheet) - if cs then - rows[#rows + 1] = cs - end - else - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - end - - WidgetLayout.column(w, h, opa, rows) + local f = WidgetUI.fonts.third + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) end ---- 1/2: title + band + status + detail + cheatsheet. +--- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - local f = WidgetUI.fonts.half - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) end ---- 1/1: title + MIDSIZE band + status + detail + cheatsheet. +--- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - local f = WidgetUI.fonts.full - local rows = VTXDisplay.buildHeroRows(f) - local cs1, cs2 = VTXDisplay.buildCheatsheetRows(f.cheatsheet) - if cs1 then - rows[#rows + 1] = cs1 - rows[#rows + 1] = cs2 - end - - WidgetLayout.column(w, h, opa, rows) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) end --- Route to the appropriate minimized layout based on widget dimensions. From d84d0617a8ef589db68f2b315b8a8fd3d999ab70 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 18 Aug 2026 10:47:28 +0300 Subject: [PATCH 217/218] redo widgets ui --- screenshots/widgets.png | Bin 73561 -> 74057 bytes src/WIDGETS/ELRSTelemetry/ui/components.lua | 250 ++++++++++++-------- src/WIDGETS/ELRSTelemetry/ui/display.lua | 12 +- src/WIDGETS/ELRSTelemetry/ui/hd.lua | 15 +- src/WIDGETS/ELRSTelemetry/ui/portrait.lua | 7 +- src/WIDGETS/ELRSTelemetry/ui/sd.lua | 13 +- src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua | 13 +- src/WIDGETS/ELRSTelemetry/ui/small.lua | 8 +- src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 208 ++++++++-------- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 123 ++++++---- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 115 +++++---- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 123 ++++++---- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 123 ++++++---- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 108 +++++---- 14 files changed, 613 insertions(+), 505 deletions(-) diff --git a/screenshots/widgets.png b/screenshots/widgets.png index 098bb46666050ed9be3699b5d12d2038d12a6be5..8377dec884297bc9a56b87150f332fda772ca81f 100644 GIT binary patch literal 74057 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

;85Xzh@Qd-9JV1g;A6`9|Y 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 Components.textWidth(BRAND, SMLSIZE) then + antLeft = antX - antCapW + Components.label(dst, { + x = antLeft, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "Ant", + visible = Display.isNotMismatch, + }) + end + local brand = Components.brandText(antLeft - m.pad - textX - modeW - nameGap) + local nameX = textX if brand then - textX = rect.x + Components.textWidth(brand, SMLSIZE) + nameGap + textX = textX + Components.textWidth(brand, SMLSIZE) + nameGap end - -- The banner runs from wherever the readings start to the dot, because the - -- cells it displaces hide with it. Shortened rather than squeezed: a banner - -- is no use clipped, and "MISMATCH!" is unambiguous under a name that has - -- just said which link it is about. - local bannerLimit = rect.x + rect.w - dotR * 2 - m.pad + -- The banner runs from wherever the readings start to the end of the row, + -- because the cells it displaces hide with it. Shortened rather than + -- squeezed: a banner is no use clipped, and "MISMATCH!" is unambiguous + -- under a name that has just said which link it is about. + local bannerLimit = rect.x + rect.w local banner = MISMATCH if Components.textWidth(banner, BOLD) > bannerLimit - textX then banner = MISMATCH_SHORT @@ -359,12 +391,12 @@ function Components.headerRow(dst, rect, y, m, spec) local nameHides = Components.textWidth(banner, BOLD) > bannerLimit - textX local bannerX = textX if nameHides then - bannerX = rect.x + bannerX = nameX end if brand then Components.label(dst, { - x = rect.x, + x = nameX, y = y, font = SMLSIZE, color = COLOR_THEME_SECONDARY1, @@ -395,15 +427,6 @@ function Components.headerRow(dst, rect, y, m, spec) }) Components.antenna(dst, { x = antX }, y, m) - Components.led(dst, { - x = dotX, - -- Centred on the text's line box, not on the row. The row is the line box - -- plus padding, so centring in it drops the dot below the text it sits - -- beside -- close enough to look like a mistake rather than a choice. - y = y + math.floor(m.sml / 2), - radius = dotR, - color = Display.ledColor, - }) return y + h end @@ -422,8 +445,11 @@ function Components.hero(dst, rect, y, m, spec) text = Display.lqHeroText, }) -- Small type beside a large number sits on its baseline, not its top: - -- labels position from the top, so the caption drops by the difference. - local drop = math.max(0, spec.h - m.sml) + -- labels position from the top, so the caption drops by the difference -- + -- four fifths of it, because line boxes carry descender room in proportion + -- to the font, and aligning box bottoms sinks the small text below the + -- shared baseline by the difference in descent. + local drop = math.max(0, math.floor((spec.h - m.sml) * 4 / 5)) Components.label(dst, { x = rect.x + Components.textWidth("100", spec.font) + m.pad, y = y + drop, @@ -532,9 +558,11 @@ end --- carry the drawing there is nothing screen-specific left in it but the --- fonts -- and five copies of the vertical arithmetic is exactly the drift --- the per-screen split is supposed to avoid. ---- Height left over after the fixed rows goes into the bars and the air ---- between blocks, never to the bottom: a panel with a hole in it is what ---- "looks basic" means. +--- The header hugs its hero -- a title floating a third of a card above its +--- figure reads as two cards -- and the block gaps are capped, so the content +--- reads as one column from the top and whatever height the zone has to +--- spare rests at the card's foot, the same shape the VTX Admin card beside +--- it takes. --- LQ gets the hero and the first bar because the uplink is what you fly on; --- the RSSI row under it keeps the rated floor in the text and draws its bar --- against that floor, so a full LQ bar over a half-full RSSI bar still reads @@ -543,32 +571,35 @@ function Components.fullTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner - -- Everything except the hero, the two bars and the air between blocks. This - -- has to match what the blocks below actually consume, AIR_GAPS included, - -- or the last row walks off the bottom of the panel. - local AIR_GAPS = 3 + local gridH = m.sml * 2 + m.gap local MIN_BAR = 4 + -- Everything except the hero and the two bars. This has to match what the + -- blocks below actually consume, or the last row walks off the bottom of + -- the panel; the bar-to-RSSI air and the footer seam both bottom out at + -- m.gap, which is what the ladder budget reserves for them. local fixedNoHero = pad * 2 + (m.sml + 4) -- header row + + m.gap -- header to hero + m.gap -- hero to LQ bar + m.sml -- RSSI row + m.gap -- RSSI row to its bar - + (m.sml * 2 + m.gap) -- grid - local heroFont, heroH = - Components.heroFromLadder(m, spec.heroLadder, f.h - fixedNoHero - MIN_BAR * 2 - AIR_GAPS * m.gap) + + gridH + local heroFont, heroH = Components.heroFromLadder(m, spec.heroLadder, f.h - fixedNoHero - MIN_BAR * 2 - m.gap * 2) local slack = f.h - fixedNoHero - heroH -- A gentle fraction of the slack: the tier also serves zones barely over -- the 1/2 breakpoint, where a generous cut of a small slack makes the bars -- thicker than the rows they sit under. The tall 1/1 still reaches the cap. local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.12), spec.barH)) - local air = math.max(m.gap, math.floor((slack - barH * 2) / AIR_GAPS)) + -- Capped: a block gap only has to separate its neighbours, and past a line + -- height it stops reading as a gap and starts reading as a hole. + local air = math.max(m.gap, math.min(math.floor(slack / 6), m.sml)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) + local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.headerRow(panel, inner, pad, m, {}) - y = Components.hero(panel, inner, y + air, m, { font = heroFont, h = heroH }) + y = Components.hero(panel, inner, y + m.gap, m, { font = heroFont, h = heroH }) y = Components.bar(panel, inner, y + m.gap, { h = barH, pct = Display.lqPct, @@ -625,7 +656,7 @@ function Components.halfTier(w, h, opa, m, spec) local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) + local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.headerRow(panel, inner, pad, m, { detail = true }) y = Components.hero(panel, inner, y + air, m, { font = spec.heroFont, h = heroH }) @@ -676,7 +707,7 @@ function Components.thirdTier(w, h, opa, m, spec) local air = math.max(0, math.floor((slack - barH) / AIR_GAPS)) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) + local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.headerRow(panel, inner, pad, m, { detail = true }) y = Components.hero(panel, inner, y + air, m, { @@ -707,10 +738,11 @@ end --- widget has. --- The name and the mode cell read as one phrase from the left -- the same --- grammar as the header's "ExpressLRS 250Hz", in one font and one colour -- ---- and the link readings pack the right margin: LQ, then the dBm pair, then ---- the status dot in the corner, where every taller tier puts it. ---- The hero label falls back to the status text: there is no header here to ---- carry a banner. +--- and the link readings pack the right margin: LQ, then the dBm pair at the +--- edge every taller tier gives it. +--- The LED keeps its lane, so a link that is down still says so on the tier +--- with no room to say it in words, and the hero label falls back to the +--- status text: there is no header here to carry a banner. function Components.compactTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner @@ -740,39 +772,34 @@ function Components.compactTier(w, h, opa, m, spec) local drop = math.max(0, heroH - m.sml) local root = {} - local panel = Components.panel(root, f.panel, { opacity = opa, rounded = spec.rounded }) + local panel = Components.panel(root, f.panel, { opacity = opa }) -- With no bar the row has the panel to itself, so it centres; with one it -- keeps the top and the bar takes the slack. local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) - -- The status dot holds the right corner, as it does on every taller tier. - local dotR = dotRadius(m) - local right = inner.x + inner.w - Components.led(panel, { - x = right - dotR, - -- Centred on the dropped small-text line the readings beside it sit on. - y = y + drop + math.floor(m.sml / 2), - radius = dotR, - color = Display.ledColor, - }) - local textRight = right - dotR * 2 - m.pad - - local textX = inner.x - local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) + -- The LED sits beside the dropped small text, not beside the hero, so it + -- centres on the dropped line: undropped it rides high of the reading by + -- the whole baseline offset, which at this size is plainly visible. + local textX = Components.ledLane(panel, inner, y + drop, m) + local textRight = inner.x + inner.w local detailW = Components.textWidth("K1000Full 2000mW", SMLSIZE) - -- The hero box is reserved against the widest string the label can ever - -- carry -- the statuses, not just the reading. A fixed-width box that the - -- live text can outgrow would wrap rather than clip, and take the row with - -- it; "Model Mismatch" wrapping over the bar is exactly what this pays for. - local heroW = math.max( - Components.textWidth("LQ 100%", spec.heroFont), - Components.textWidth("No CRSF module", spec.heroFont), - Components.textWidth("Model Mismatch", spec.heroFont) - ) - -- Six pads: the name and the detail advance by two each, and the signal box - -- keeps two off the hero. Reserving fewer lets the advances eat into the - -- hero's own reserve. - local avail = textRight - textX - signalW - heroW - m.pad * 6 + local heroW = Components.textWidth("LQ 100%", spec.heroFont) + -- The signal has its own fit ladder: the reading against the rated floor + -- where the row affords it, the bare reading where carrying the floor would + -- cost the widget its name -- a decorated half-width zone is exactly that + -- wide, and an anonymous row of numbers is worth less than one dBm figure. + local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) + local signalText = Display.signalShortText + local avail = textRight - textX - signalW - heroW - m.pad * 3 + if avail < Components.textWidth(BRAND_SHORT, SMLSIZE) then + local shortW = Components.textWidth("-105dBm", SMLSIZE) + local shortAvail = textRight - textX - shortW - heroW - m.pad * 3 + if shortAvail >= Components.textWidth(BRAND_SHORT, SMLSIZE) then + signalW = shortW + signalText = Display.rssiText + avail = shortAvail + end + end local showDetail = avail >= detailW + m.pad if showDetail then avail = avail - detailW - m.pad @@ -805,9 +832,11 @@ function Components.compactTier(w, h, opa, m, spec) }) textX = textX + detailW + m.pad * 2 end - -- LQ's box spans the middle, right-aligned against the dBm pair: normally - -- only the reserved right end of it is inked, and a status -- the one long - -- string this label ever carries -- grows leftward across the row the + -- The reading and the status are two labels swapping over one spot, the + -- same mechanism as the header's mismatch banner. The reading keeps a + -- reserved box, right-aligned against the dBm pair; the status label is + -- content-sized -- a fixed box it could outgrow would wrap rather than + -- clip, and take the row with it -- and grows rightward across the row the -- hidden readings have just emptied. local heroRight = textRight - signalW - m.pad * 2 Components.label(panel, { @@ -816,8 +845,16 @@ function Components.compactTier(w, h, opa, m, spec) w = math.max(1, heroRight - textX), align = RIGHT, font = spec.heroFont, + text = Display.lqText, + visible = Display.noStatus, + }) + Components.label(panel, { + x = textX, + y = y, + font = spec.heroFont, color = Display.heroColor, - text = Display.heroText, + text = Display.statusText, + visible = Display.hasStatus, }) Components.label(panel, { x = textRight - signalW, @@ -826,12 +863,15 @@ function Components.compactTier(w, h, opa, m, spec) align = RIGHT, font = SMLSIZE, color = Display.detailColor, - text = Display.signalShortText, + text = signalText, visible = Display.isNotMismatch, }) if barH > 0 then - Components.bar(panel, inner, vpad + heroH + air, { + -- Tucked under the row rather than sitting on the panel's floor: the bar + -- qualifies the readings above it, and a zone taller than the minimum + -- leaves its spare height under the bar, not between them. + Components.bar(panel, inner, vpad + heroH + math.min(air, m.gap * 2), { h = barH, pct = Display.headroomPct, color = Display.headroomBarColor, diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua index 70cf971..932deeb 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/display.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -177,9 +177,15 @@ function Display.rssiText(sep) return table.concat({ tostring(rssi), sep or "", "dBm" }) end ---- The hero label: a status when there is one, otherwise link quality. -function Display.heroText() - return Display.statusText() or Display.lqText() +--- Whether a status is standing, for the labels that swap places with the +--- readings: the status label takes hasStatus, the reading it displaces takes +--- noStatus, and the pair occupy the same rect so nothing reflows. +function Display.hasStatus() + return Display.statusText() ~= nil +end + +function Display.noStatus() + return Display.statusText() == nil end --- RSSI against the rate's rated floor, e.g. "-90 / -112 dBm". diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua index 0b59675..18cb632 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/hd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -30,18 +30,17 @@ WidgetUI.breakpoints = { -- The full tier's hero is a ladder, not a size: the tier serves every zone -- from the 209px half up to the 418px full, and the builder takes the largest --- font the zone's height budget affords. This screen's fonts are large enough --- that BOLD never earns a rung. +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card -- and +-- this screen's fonts are large enough that BOLD never earns a rung. WidgetUI.fonts = { compact = { hero = SMLSIZE }, third = { hero = MIDSIZE }, half = { hero = MIDSIZE }, - full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE } }, + full = { heroLadder = { DBLSIZE, MIDSIZE } }, } --- The card's corner radius and the cap on bar thickness, in this screen's --- pixels. -local ROUNDED = 8 +-- The cap on bar thickness, in this screen's pixels. local BAR_H = 8 -- ============================================================================ @@ -57,7 +56,6 @@ function WidgetUI.buildCompact(w, h, opa) Components.compactTier(w, h, opa, m, { heroFont = WidgetUI.fonts.compact.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -67,7 +65,6 @@ function WidgetUI.buildThird(w, h, opa) Components.thirdTier(w, h, opa, m, { heroFont = WidgetUI.fonts.third.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -79,7 +76,6 @@ function WidgetUI.buildHalf(w, h, opa) Components.halfTier(w, h, opa, m, { heroFont = WidgetUI.fonts.half.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -91,7 +87,6 @@ function WidgetUI.buildFull(w, h, opa) Components.fullTier(w, h, opa, m, { heroLadder = WidgetUI.fonts.full.heroLadder, barH = BAR_H, - rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua index e5bcc70..d95ada5 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -39,9 +39,7 @@ WidgetUI.fonts = { full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, } --- The card's corner radius and the cap on bar thickness, in this screen's --- pixels. -local ROUNDED = 4 +-- The cap on bar thickness, in this screen's pixels. local BAR_H = 5 -- ============================================================================ @@ -57,7 +55,6 @@ function WidgetUI.buildCompact(w, h, opa) Components.compactTier(w, h, opa, m, { heroFont = WidgetUI.fonts.compact.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -67,7 +64,6 @@ function WidgetUI.buildThird(w, h, opa) Components.thirdTier(w, h, opa, m, { heroFont = WidgetUI.fonts.third.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -79,7 +75,6 @@ function WidgetUI.buildFull(w, h, opa) Components.fullTier(w, h, opa, m, { heroLadder = WidgetUI.fonts.full.heroLadder, barH = BAR_H, - rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua index a9f50c4..161291c 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -30,17 +30,16 @@ WidgetUI.breakpoints = { -- The full tier's hero is a ladder, not a size: the tier serves every zone -- from the 113px half up to the 227px full, and the builder takes the largest --- font the zone's height budget affords. +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card. WidgetUI.fonts = { compact = { hero = SMLSIZE }, third = { hero = BOLD }, half = { hero = BOLD }, - full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE, BOLD } }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, } --- The card's corner radius and the cap on bar thickness, in this screen's --- pixels. -local ROUNDED = 6 +-- The cap on bar thickness, in this screen's pixels. local BAR_H = 6 -- ============================================================================ @@ -56,7 +55,6 @@ function WidgetUI.buildCompact(w, h, opa) Components.compactTier(w, h, opa, m, { heroFont = WidgetUI.fonts.compact.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -66,7 +64,6 @@ function WidgetUI.buildThird(w, h, opa) Components.thirdTier(w, h, opa, m, { heroFont = WidgetUI.fonts.third.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -78,7 +75,6 @@ function WidgetUI.buildHalf(w, h, opa) Components.halfTier(w, h, opa, m, { heroFont = WidgetUI.fonts.half.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -90,7 +86,6 @@ function WidgetUI.buildFull(w, h, opa) Components.fullTier(w, h, opa, m, { heroLadder = WidgetUI.fonts.full.heroLadder, barH = BAR_H, - rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua index 2be454b..fcfebc8 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -31,17 +31,16 @@ WidgetUI.breakpoints = { -- The full tier's hero is a ladder, not a size: the tier serves every zone -- from the 137px half up to the 275px full, and the builder takes the largest --- font the zone's height budget affords. +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card. WidgetUI.fonts = { compact = { hero = SMLSIZE }, third = { hero = BOLD }, half = { hero = BOLD }, - full = { heroLadder = { XXLSIZE, DBLSIZE, MIDSIZE, BOLD } }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, } --- The card's corner radius and the cap on bar thickness, in this screen's --- pixels. -local ROUNDED = 6 +-- The cap on bar thickness, in this screen's pixels. local BAR_H = 6 -- ============================================================================ @@ -57,7 +56,6 @@ function WidgetUI.buildCompact(w, h, opa) Components.compactTier(w, h, opa, m, { heroFont = WidgetUI.fonts.compact.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -67,7 +65,6 @@ function WidgetUI.buildThird(w, h, opa) Components.thirdTier(w, h, opa, m, { heroFont = WidgetUI.fonts.third.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -79,7 +76,6 @@ function WidgetUI.buildHalf(w, h, opa) Components.halfTier(w, h, opa, m, { heroFont = WidgetUI.fonts.half.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -91,7 +87,6 @@ function WidgetUI.buildFull(w, h, opa) Components.fullTier(w, h, opa, m, { heroLadder = WidgetUI.fonts.full.heroLadder, barH = BAR_H, - rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua index ec8ead6..af4c0f2 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/small.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -39,9 +39,7 @@ WidgetUI.fonts = { full = { heroLadder = { MIDSIZE, BOLD } }, } --- The card's corner radius and the cap on bar thickness, in this screen's --- pixels. -local ROUNDED = 4 +-- The cap on bar thickness, in this screen's pixels. local BAR_H = 5 -- ============================================================================ @@ -57,7 +55,6 @@ function WidgetUI.buildCompact(w, h, opa) Components.compactTier(w, h, opa, m, { heroFont = WidgetUI.fonts.compact.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -67,7 +64,6 @@ function WidgetUI.buildThird(w, h, opa) Components.thirdTier(w, h, opa, m, { heroFont = WidgetUI.fonts.third.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -79,7 +75,6 @@ function WidgetUI.buildHalf(w, h, opa) Components.halfTier(w, h, opa, m, { heroFont = WidgetUI.fonts.half.hero, barH = BAR_H, - rounded = ROUNDED, }) end @@ -91,7 +86,6 @@ function WidgetUI.buildFull(w, h, opa) Components.fullTier(w, h, opa, m, { heroLadder = WidgetUI.fonts.full.heroLadder, barH = BAR_H, - rounded = ROUNDED, }) end diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index 88931da..8218245 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -21,9 +21,7 @@ local WidgetLayout = {} --- costs more height than it has. Pass a table to keep the horizontal padding --- while trimming the vertical: the left edge is what a widget in the zone --- above or beside lines its own text up against, so it is not the one to give. ---- rounded is the card's corner radius, on the fill alone: the box draws ---- nothing, so its square corners cannot show. -function WidgetLayout.column(w, h, opa, children, pad, rounded) +function WidgetLayout.column(w, h, opa, children, pad) lvgl.build({ { type = lvgl.RECTANGLE, @@ -34,7 +32,6 @@ function WidgetLayout.column(w, h, opa, children, pad, rounded) color = COLOR_THEME_PRIMARY2, opacity = opa, filled = true, - rounded = rounded, }, { type = lvgl.BOX, @@ -51,7 +48,7 @@ function WidgetLayout.column(w, h, opa, children, pad, rounded) }) end -function WidgetLayout.row(w, h, opa, children, rounded) +function WidgetLayout.row(w, h, opa, children) lvgl.build({ { type = lvgl.RECTANGLE, @@ -62,7 +59,6 @@ function WidgetLayout.row(w, h, opa, children, rounded) color = COLOR_THEME_PRIMARY2, opacity = opa, filled = true, - rounded = rounded, }, { type = lvgl.BOX, @@ -151,9 +147,24 @@ function VTXDisplay.pitHeaderColor() return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1 end ---- The power level on its own, for the power row's right end. -function VTXDisplay.powerValueText() - return tostring(VTXAdmin.state.power) +--- The power level in the terse form that rides beside the channel on the +--- one-line tiers, e.g. "P2". "" when no power is set: ExpressLRS cannot +--- report one, and a bare "P" would read as a reading stuck mid-arrival. +function VTXDisplay.powerShort() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "P", VTXAdmin.state.power }) +end + +--- The same reading said in full, e.g. "Power 2", for the hero row -- at that +--- size the terse form reads as part of the channel rather than as its own +--- reading. +function VTXDisplay.powerLong() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "Power ", VTXAdmin.state.power }) end --- The header row: the widget's name in the muted caption colour on the left, @@ -192,89 +203,110 @@ function VTXDisplay.buildHeader(w) } end ---- The hero pair for a flex column: the status while the VTX is quiet, the ---- band and channel in the tier's display font once it is tuned. Two children ---- to append in order -- a hidden flex child costs no height, so only the live ---- one takes a row. +--- The hero pair for a flex column: the status while the VTX is quiet, and +--- once it is tuned the band and channel in the tier's display font with +--- "Power 2" sat on its baseline beside it. Two children to append in order +--- -- a hidden flex child costs no height, so only the live one takes a row. +--- The status takes the same display font as the reading it stands in for: +--- the two swap over one row, and at two sizes everything below them jumps +--- the moment the VTX loads. +--- A plain box with absolute children rather than a flex row: small type +--- beside a large number sits on its baseline, not its top, and flex has no +--- way to say that. Same mechanics as the telemetry hero's caption -- the +--- power's x is reserved against the widest reading, so it never moves when +--- the channel changes. function VTXDisplay.buildHero(font) + local heroH = select(2, lcd.sizeText("0", font)) + -- Four fifths of the height difference, not all of it: line boxes carry + -- descender room in proportion to the font, so aligning box bottoms sinks + -- the small text below the shared baseline by the difference in descent. + local drop = math.max(0, math.floor((heroH - select(2, lcd.sizeText("0", SMLSIZE))) * 4 / 5)) return { type = lvgl.LABEL, align = LEFT, - font = BOLD, - color = COLOR_THEME_PRIMARY1, + font = font, + -- The muted theme colour, not the text primary: at the hero size a black + -- "Loading..." reads as the reading itself, and a status is background. + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, }, { - type = lvgl.LABEL, - align = LEFT, - font = font, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, + type = lvgl.BOX, + h = heroH, visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = font, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + x = (lcd.sizeText("R8", font)) + lvgl.PAD_MEDIUM, + y = drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerLong, + }, + }, } end ---- The 6POS presets as a row of six equal cells, the latched one lit in the ---- accent. The row is always exactly six cells and never reflows: a preset ---- without a band shows "--" in its cell rather than giving the cell up, and ---- the position is the cell's place in the row, so the labels drop the "n:" ---- prefix the text cheatsheet needed. ---- The active cell recolours through closures -- the cells themselves never ---- move or resize, which keeps the row to zero layout work per frame. ---- spec.w is the width the row may span; spec.cellH, spec.font and ---- spec.rounded come from the screen file's own scale. ---- Returns nil when the presets feature is off or no module is present, the ---- same build-time gates the text cheatsheet had; the runtime module gate ---- rides the row's visible. +--- The 6POS presets as a row of six fixed-width cells, the latched one lit in +--- the accent. Always exactly six cells: an unset preset shows "--" rather +--- than giving its cell up, so the position is the cell's place in the row. +--- Everything live rides in closures -- colours and label text -- so the +--- cells themselves never move or resize, and presets edited in another +--- instance's editor show up without a rebuild. +--- spec.cellH, spec.font and spec.rounded come from the screen file's scale. +--- Returns nil when the presets feature is off or no module is present. function VTXDisplay.buildCells(spec) - if not VTXAdmin.hasModule() then - return nil - end - if not PresetsStorage.enabled then + if not (VTXAdmin.hasModule() and PresetsStorage.enabled) then return nil end - -- The mock's own cell gap. Tighter than the theme paddings, and fixed: six - -- cells only read as one control when the gaps between them are beats, not - -- breaks. - local gap = 4 - local cellW = math.floor((spec.w - 5 * gap) / 6) local font = spec.font or SMLSIZE - local textY = math.floor((spec.cellH - select(2, lcd.sizeText("0", font))) / 2) + -- One width for all six, measured against the widest label a cell can carry. + local cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + local function isActive(idx) + return PresetsStorage.latch.lastPos == idx + end + local function cellText(idx) + local p = PresetsStorage.items[idx] + if p.band == 0 then + return "--" + end + return table.concat({ VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + end local cells = {} - for i = 1, 6 do - local idx = i - cells[#cells + 1] = { + for idx = 1, 6 do + cells[idx] = { type = lvgl.RECTANGLE, w = cellW, h = spec.cellH, filled = true, rounded = spec.rounded, color = function() - return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_FOCUS or COLOR_THEME_DISABLED + return isActive(idx) and COLOR_THEME_FOCUS or COLOR_THEME_DISABLED end, - -- The inactive cells sit back at part opacity so the lit one carries - -- the row; opacity is background-only on a rectangle, so the label - -- inside keeps its full weight either way. + -- Part opacity is background-only on a rectangle, so the inactive cells + -- sit back while their labels keep full weight. opacity = function() - return (PresetsStorage.latch.lastPos == idx) and 255 or 90 + return isActive(idx) and 255 or 90 end, children = { { type = lvgl.LABEL, - x = 0, - y = math.max(0, textY), w = cellW, - align = CENTER, + align = CENTER + VCENTER, font = font, color = function() - return (PresetsStorage.latch.lastPos == idx) and COLOR_THEME_PRIMARY2 or COLOR_THEME_SECONDARY1 + return isActive(idx) and COLOR_THEME_PRIMARY2 or COLOR_THEME_SECONDARY1 end, text = function() - local p = PresetsStorage.items[idx] - if p.band == 0 then - return "--" - end - return table.concat({ VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + return cellText(idx) end, }, }, @@ -284,59 +316,27 @@ function VTXDisplay.buildCells(spec) type = lvgl.BOX, flexFlow = lvgl.FLOW_ROW, borderPad = 0, - flexPad = gap, + -- Tighter than the theme paddings: six cells read as one control when the + -- gaps between them are beats, not breaks. + flexPad = 4, align = LEFT, visible = VTXAdmin.hasModule, children = cells, } end ---- The power row at the card's foot: the caption on the left, the level ---- right-aligned. Same box shape as the header, and hidden whole -- a flex ---- column drops a hidden child rather than reserving its line, so a VTX ---- reporting no power closes the row instead of leaving a gap. -function VTXDisplay.buildPowerRow(w) - local cw = w - 2 * lvgl.PAD_SMALL - return { - type = lvgl.BOX, - w = cw, - visible = VTXAdmin.hasPower, - children = { - { - type = lvgl.LABEL, - x = 0, - y = 0, - font = SMLSIZE, - color = COLOR_THEME_SECONDARY1, - text = "Power", - }, - { - type = lvgl.LABEL, - x = 0, - y = 0, - w = cw, - align = RIGHT, - font = SMLSIZE, - color = COLOR_THEME_PRIMARY1, - text = VTXDisplay.powerValueText, - }, - }, - } -end - ---- The headline row: the widget's name, then the band and channel, on one line ---- at one size. For the tiers with no row to spare for a title of its own. ---- Both at one size on purpose. The name is a caption in the muted theme colour ---- and the reading takes the accent, which is what lets them share a line ---- without competing -- where two type sizes in one breath read as two ---- importances, and a name shrunk beside its own value reads as an afterthought. +--- The headline row: the widget's name, then the band and channel, on one line. +--- For the tiers with no row to spare for a title of its own. The name keeps +--- the small muted caption type every other tier titles itself with; only the +--- reading takes the tier's font and the accent, so the emphasis falls on the +--- reading at every widget size. --- extras are trailing labels, for a tier with nowhere else to put them. function VTXDisplay.buildHeadline(w, font, extras) local children = { { type = lvgl.LABEL, align = LEFT, - font = font, + font = SMLSIZE, color = COLOR_THEME_SECONDARY1, text = "VTX Admin", }, @@ -348,6 +348,14 @@ function VTXDisplay.buildHeadline(w, font, extras) text = VTXDisplay.bandChannel, visible = VTXDisplay.showChannel, }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, } for i = 1, extras and #extras or 0 do children[#children + 1] = extras[i] diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 9d3e1cc..332af1f 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -30,16 +30,14 @@ WidgetUI.breakpoints = { -- Every tier that draws the preset cells sizes them here: cellH is the cell -- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD, cells = SMLSIZE, cellH = 18 }, - quarter = { status = BOLD, cells = SMLSIZE, cellH = 24 }, - third = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, - half = { hero = MIDSIZE, cells = STDSIZE, cellH = 27 }, - full = { hero = DBLSIZE, cells = STDSIZE, cellH = 30 }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 22 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 26 }, + third = { hero = MIDSIZE, cells = STDSIZE, cellH = 28 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 30 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 32 }, } --- The card's corner radius, and the smaller one on the preset cells, in this --- screen's pixels. -local ROUNDED = 8 +-- The corner radius on the preset cells, in this screen's pixels. local CELL_ROUNDED = 6 -- ============================================================================ @@ -58,30 +56,29 @@ local function statusLabel() type = lvgl.LABEL, align = LEFT, font = BOLD, - color = COLOR_THEME_PRIMARY1, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } end ---- The preset cells sized for one tier, spanning the container's content ---- width. nil when the presets feature is off, same as the builder. -local function cells(f, w) +--- The preset cells sized for one tier; cellH overrides the tier's own for a +--- layout that sizes them to its zone. nil when the presets feature is off, +--- same as the builder. +local function cells(f, cellH) return VTXDisplay.buildCells({ - w = w - 2 * lvgl.PAD_SMALL, - cellH = f.cellH, + cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, }) end ---- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, ---- the preset cells, the power row at the foot. +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + rows[#rows + 1] = cells(f) return rows end @@ -93,13 +90,14 @@ end --- vertical padding gives and the left edge keeps it -- that edge is what a --- widget in the zone above lines its own text up against. --- A zone too short for two rows, or too narrow to carry six cells on one, ---- falls back to a single inline row, which has no width to spare for the ---- name. Fixed-width band column prevents layout jumping when values change. +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f, w) + local cellRow = cells(f) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -108,34 +106,54 @@ function WidgetUI.buildSixth(w, h, opa) left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, - bottom = lvgl.PAD_TINY, - }, ROUNDED) + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) return end end - local c1w = math.floor(w * 0.22) - local columns = { - statusLabel(), - { + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { type = lvgl.LABEL, - w = c1w, - font = f.status, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, } if w >= bp.wideW then - columns[#columns + 1] = VTXDisplay.buildCells({ - w = math.floor(w * 0.55), - cellH = f.cellH, - font = f.cells, - rounded = CELL_ROUNDED, - }) + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end - WidgetLayout.row(w, h, opa, columns, ROUNDED) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is @@ -145,29 +163,30 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + rows[#rows + 1] = cells(f) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) end ---- 1/3: the header with the pit state, the hero on its own line, the cells. ---- The power row is what gives at this height; a confirmed pit mode still ---- shows, in the header and on the hero's colour. +--- 1/3: the whole card at the tier's own type scale. function WidgetUI.buildThird(w, h, opa) - local f = WidgetUI.fonts.third - local heroStatus, hero = VTXDisplay.buildHero(f.hero) - local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) end --- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) end --- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index cf34d59..e09fca4 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -28,16 +28,15 @@ WidgetUI.breakpoints = { -- Every tier that draws the preset cells sizes them here: cellH is the cell -- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD, cells = SMLSIZE, cellH = 12 }, - quarter = { status = BOLD, cells = SMLSIZE, cellH = 14 }, - third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 14 }, - half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 16 }, - full = { hero = DBLSIZE, cells = STDSIZE, cellH = 18 }, + -- No cellH: the 1/6 inline row sizes its cells to the zone itself. + sixth = { status = BOLD, cells = SMLSIZE }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 18 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 24 }, } --- The card's corner radius, and the smaller one on the preset cells, in this --- screen's pixels. -local ROUNDED = 4 +-- The corner radius on the preset cells, in this screen's pixels. local CELL_ROUNDED = 3 -- ============================================================================ @@ -56,62 +55,77 @@ local function statusLabel() type = lvgl.LABEL, align = LEFT, font = BOLD, - color = COLOR_THEME_PRIMARY1, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } end ---- The preset cells sized for one tier, spanning the container's content ---- width. nil when the presets feature is off, same as the builder. -local function cells(f, w) +--- The preset cells sized for one tier; cellH overrides the tier's own for a +--- layout that sizes them to its zone. nil when the presets feature is off, +--- same as the builder. +local function cells(f, cellH) return VTXDisplay.buildCells({ - w = w - 2 * lvgl.PAD_SMALL, - cellH = f.cellH, + cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, }) end ---- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, ---- the preset cells, the power row at the foot. +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + rows[#rows + 1] = cells(f) return rows end ---- 1/6: a single inline row -- the status or the band and channel, then the ---- preset cells where the width allows. No stacked variant here; the name is ---- what this tier gives up. +--- 1/6: a single inline row -- the name where a full-width zone has the +--- width for it, the status or the band and channel with its power level, +--- then the preset cells. No stacked variant here. --- Fixed-width band column prevents layout jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth - local c1w = math.floor(w * 0.22) - local columns = { - statusLabel(), - { + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { type = lvgl.LABEL, - w = c1w, - font = f.status, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, } if w >= bp.wideW then - columns[#columns + 1] = VTXDisplay.buildCells({ - w = math.floor(w * 0.55), - cellH = f.cellH, - font = f.cells, - rounded = CELL_ROUNDED, - }) + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end - WidgetLayout.row(w, h, opa, columns, ROUNDED) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is @@ -121,29 +135,30 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + rows[#rows + 1] = cells(f) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) end ---- 1/3: the header with the pit state, the hero on its own line, the cells. ---- The power row is what gives at this height; a confirmed pit mode still ---- shows, in the header and on the hero's colour. +--- 1/3: the whole card at the tier's own type scale. function WidgetUI.buildThird(w, h, opa) - local f = WidgetUI.fonts.third - local heroStatus, hero = VTXDisplay.buildHero(f.hero) - local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) end --- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) end --- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 9d75c6a..1ecf9b3 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -30,16 +30,14 @@ WidgetUI.breakpoints = { -- Every tier that draws the preset cells sizes them here: cellH is the cell -- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD, cells = SMLSIZE, cellH = 14 }, - quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, - third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 18 }, - half = { hero = MIDSIZE, cells = STDSIZE, cellH = 20 }, - full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 20 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 22 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 26 }, } --- The card's corner radius, and the smaller one on the preset cells, in this --- screen's pixels. -local ROUNDED = 6 +-- The corner radius on the preset cells, in this screen's pixels. local CELL_ROUNDED = 4 -- ============================================================================ @@ -58,30 +56,29 @@ local function statusLabel() type = lvgl.LABEL, align = LEFT, font = BOLD, - color = COLOR_THEME_PRIMARY1, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } end ---- The preset cells sized for one tier, spanning the container's content ---- width. nil when the presets feature is off, same as the builder. -local function cells(f, w) +--- The preset cells sized for one tier; cellH overrides the tier's own for a +--- layout that sizes them to its zone. nil when the presets feature is off, +--- same as the builder. +local function cells(f, cellH) return VTXDisplay.buildCells({ - w = w - 2 * lvgl.PAD_SMALL, - cellH = f.cellH, + cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, }) end ---- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, ---- the preset cells, the power row at the foot. +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + rows[#rows + 1] = cells(f) return rows end @@ -93,13 +90,14 @@ end --- vertical padding gives and the left edge keeps it -- that edge is what a --- widget in the zone above lines its own text up against. --- A zone too short for two rows, or too narrow to carry six cells on one, ---- falls back to a single inline row, which has no width to spare for the ---- name. Fixed-width band column prevents layout jumping when values change. +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f, w) + local cellRow = cells(f) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -108,34 +106,54 @@ function WidgetUI.buildSixth(w, h, opa) left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, - bottom = lvgl.PAD_TINY, - }, ROUNDED) + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) return end end - local c1w = math.floor(w * 0.22) - local columns = { - statusLabel(), - { + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { type = lvgl.LABEL, - w = c1w, - font = f.status, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, } if w >= bp.wideW then - columns[#columns + 1] = VTXDisplay.buildCells({ - w = math.floor(w * 0.55), - cellH = f.cellH, - font = f.cells, - rounded = CELL_ROUNDED, - }) + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end - WidgetLayout.row(w, h, opa, columns, ROUNDED) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is @@ -145,29 +163,30 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + rows[#rows + 1] = cells(f) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) end ---- 1/3: the header with the pit state, the hero on its own line, the cells. ---- The power row is what gives at this height; a confirmed pit mode still ---- shows, in the header and on the hero's colour. +--- 1/3: the whole card at the tier's own type scale. function WidgetUI.buildThird(w, h, opa) - local f = WidgetUI.fonts.third - local heroStatus, hero = VTXDisplay.buildHero(f.hero) - local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) end --- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) end --- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 8bee7a6..03d37b7 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -32,16 +32,14 @@ WidgetUI.breakpoints = { -- The third tier's hero stays BOLD: its rung starts at 62px here, which is -- too short for MIDSIZE over a cell row. WidgetUI.fonts = { - sixth = { status = BOLD, cells = SMLSIZE, cellH = 14 }, - quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, - third = { hero = BOLD, cells = SMLSIZE, cellH = 18 }, - half = { hero = MIDSIZE, cells = STDSIZE, cellH = 20 }, - full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, + sixth = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 20 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 20 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 26 }, } --- The card's corner radius, and the smaller one on the preset cells, in this --- screen's pixels. -local ROUNDED = 6 +-- The corner radius on the preset cells, in this screen's pixels. local CELL_ROUNDED = 4 -- ============================================================================ @@ -60,30 +58,29 @@ local function statusLabel() type = lvgl.LABEL, align = LEFT, font = BOLD, - color = COLOR_THEME_PRIMARY1, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } end ---- The preset cells sized for one tier, spanning the container's content ---- width. nil when the presets feature is off, same as the builder. -local function cells(f, w) +--- The preset cells sized for one tier; cellH overrides the tier's own for a +--- layout that sizes them to its zone. nil when the presets feature is off, +--- same as the builder. +local function cells(f, cellH) return VTXDisplay.buildCells({ - w = w - 2 * lvgl.PAD_SMALL, - cellH = f.cellH, + cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, }) end ---- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, ---- the preset cells, the power row at the foot. +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + rows[#rows + 1] = cells(f) return rows end @@ -95,13 +92,14 @@ end --- vertical padding gives and the left edge keeps it -- that edge is what a --- widget in the zone above lines its own text up against. --- A zone too short for two rows, or too narrow to carry six cells on one, ---- falls back to a single inline row, which has no width to spare for the ---- name. Fixed-width band column prevents layout jumping when values change. +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f, w) + local cellRow = cells(f) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -110,34 +108,54 @@ function WidgetUI.buildSixth(w, h, opa) left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, top = lvgl.PAD_TINY, - bottom = lvgl.PAD_TINY, - }, ROUNDED) + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) return end end - local c1w = math.floor(w * 0.22) - local columns = { - statusLabel(), - { + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { type = lvgl.LABEL, - w = c1w, - font = f.status, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, } if w >= bp.wideW then - columns[#columns + 1] = VTXDisplay.buildCells({ - w = math.floor(w * 0.55), - cellH = f.cellH, - font = f.cells, - rounded = CELL_ROUNDED, - }) + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end - WidgetLayout.row(w, h, opa, columns, ROUNDED) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is @@ -147,29 +165,30 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + rows[#rows + 1] = cells(f) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) end ---- 1/3: the header with the pit state, the hero on its own line, the cells. ---- The power row is what gives at this height; a confirmed pit mode still ---- shows, in the header and on the hero's colour. +--- 1/3: the whole card at the tier's own type scale. function WidgetUI.buildThird(w, h, opa) - local f = WidgetUI.fonts.third - local heroStatus, hero = VTXDisplay.buildHero(f.hero) - local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) end --- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) end --- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) end --- Route to the appropriate minimized layout based on widget dimensions. diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index f01d90b..725daad 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -28,16 +28,15 @@ WidgetUI.breakpoints = { -- Every tier that draws the preset cells sizes them here: cellH is the cell -- box in this screen's pixels, cells the font inside it. WidgetUI.fonts = { - sixth = { status = BOLD, cells = SMLSIZE, cellH = 12 }, - quarter = { status = BOLD, cells = SMLSIZE, cellH = 14 }, - third = { hero = BOLD, cells = SMLSIZE, cellH = 14 }, - half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 16 }, - full = { hero = DBLSIZE, cells = STDSIZE, cellH = 18 }, + -- No cellH: the 1/6 inline row sizes its cells to the zone itself. + sixth = { status = BOLD, cells = SMLSIZE }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 16 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, } --- The card's corner radius, and the smaller one on the preset cells, in this --- screen's pixels. -local ROUNDED = 4 +-- The corner radius on the preset cells, in this screen's pixels. local CELL_ROUNDED = 3 -- ============================================================================ @@ -56,62 +55,77 @@ local function statusLabel() type = lvgl.LABEL, align = LEFT, font = BOLD, - color = COLOR_THEME_PRIMARY1, + color = COLOR_THEME_SECONDARY1, text = VTXDisplay.statusText, visible = VTXDisplay.showStatus, } end ---- The preset cells sized for one tier, spanning the container's content ---- width. nil when the presets feature is off, same as the builder. -local function cells(f, w) +--- The preset cells sized for one tier; cellH overrides the tier's own for a +--- layout that sizes them to its zone. nil when the presets feature is off, +--- same as the builder. +local function cells(f, cellH) return VTXDisplay.buildCells({ - w = w - 2 * lvgl.PAD_SMALL, - cellH = f.cellH, + cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, }) end ---- The card the 1/2 and 1/1 tiers share: header with the pit state, the hero, ---- the preset cells, the power row at the foot. +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - rows[#rows + 1] = VTXDisplay.buildPowerRow(w) + rows[#rows + 1] = cells(f) return rows end ---- 1/6: a single inline row -- the status or the band and channel, then the ---- preset cells where a full-width zone has the width for them. No stacked ---- variant here; the name is what this screen's 1/6 gives up. +--- 1/6: a single inline row -- the name where a full-width zone has the +--- width for it, the status or the band and channel with its power level, +--- then the preset cells. No stacked variant here. --- Fixed-width band column prevents layout jumping when values change. function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth - local c1w = math.floor(w * 0.22) - local columns = { - statusLabel(), - { + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { type = lvgl.LABEL, - w = c1w, - font = f.status, - color = VTXDisplay.heroColor, - text = VTXDisplay.bandChannel, - visible = VTXDisplay.showChannel, - }, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, } if w >= bp.wideW then - columns[#columns + 1] = VTXDisplay.buildCells({ - w = math.floor(w * 0.55), - cellH = f.cellH, - font = f.cells, - rounded = CELL_ROUNDED, - }) + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end - WidgetLayout.row(w, h, opa, columns, ROUNDED) + WidgetLayout.row(w, h, opa, columns) end --- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is @@ -121,29 +135,23 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + rows[#rows + 1] = cells(f) + WidgetLayout.column(w, h, opa, rows) end ---- 1/3: the header with the pit state, the hero on its own line, the cells. ---- The power row is what gives at this height; a confirmed pit mode still ---- shows, in the header and on the hero's colour. +--- 1/3: the whole card at the tier's own type scale. function WidgetUI.buildThird(w, h, opa) - local f = WidgetUI.fonts.third - local heroStatus, hero = VTXDisplay.buildHero(f.hero) - local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f, w) - WidgetLayout.column(w, h, opa, rows, nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) end --- 1/2: the whole card. function WidgetUI.buildHalf(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) end --- 1/1: the whole card, one step up in type. function WidgetUI.buildFull(w, h, opa) - WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w), nil, ROUNDED) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) end --- Route to the appropriate minimized layout based on widget dimensions. From 0156e7da1027cffc3a601c4bc1c3e5367cb60644 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Tue, 18 Aug 2026 12:43:08 +0300 Subject: [PATCH 218/218] update rssi, update flysky compatiblity --- src/WIDGETS/ELRSTelemetry/ui/components.lua | 98 +++++++++++++++++---- src/WIDGETS/ELRSVTXAdmin/ui/display.lua | 17 +++- src/WIDGETS/ELRSVTXAdmin/ui/hd.lua | 17 ++-- src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua | 15 ++-- src/WIDGETS/ELRSVTXAdmin/ui/sd.lua | 17 ++-- src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua | 17 ++-- src/WIDGETS/ELRSVTXAdmin/ui/small.lua | 15 ++-- 7 files changed, 140 insertions(+), 56 deletions(-) diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua index 8a969c7..eeadce1 100644 --- a/src/WIDGETS/ELRSTelemetry/ui/components.lua +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -44,6 +44,12 @@ local BRAND_SHORT = "ELRS" local MISMATCH = "MODEL MISMATCH!" local MISMATCH_SHORT = "MISMATCH!" +-- The signal fit ladder's reserve strings: the widest string each form can +-- render, so a narrowing row trades forms at a stable width instead of +-- reflowing with the values. +local SIGNAL_FULL = "-105 -105 / -105 dBm" +local SIGNAL_SHORT = "-105 / -105 dBm" + --- Slot side of one antenna dot. Half the row, so the pair sits at the status --- LED's weight rather than the text's. local function antCell(m) @@ -494,15 +500,46 @@ function Components.valueRow(dst, rect, y, m, spec) return y + m.sml end +--- The narrowest column that holds every cell of the grid: its widest caption +--- plus the gap plus its widest value, reserved rather than measured live so +--- the answer does not change with the readings. +local function gridMinColW(m, rows) + local need = 0 + for i = 1, #rows do + for j = 1, #rows[i] do + local cell = rows[i][j] + local cw = Components.textWidth(cell.caption, SMLSIZE) + m.pad + Components.textWidth(cell.reserve, SMLSIZE) + if cw > need then + need = cw + end + end + end + return need +end + --- The reading grid: two equal columns of caption-value cells, in rows. --- The captions are the sensor names EdgeTX itself puts in the model's --- telemetry list, mixed case and all, so the grid reads straight across to --- that list and to the module's own screen. --- Values sit a fixed gap after their caption, left-aligned, so a reading --- gaining a digit grows into its own column's slack and nothing reflows. ---- rows is a list of rows, each a list of { caption, text } cells. +--- rows is a list of rows, each a list of { caption, text, reserve } cells -- +--- reserve being the widest string the value can render. +--- A zone too narrow for two columns lays the same cells out one per line +--- instead: a value running under its neighbour's caption reads as garbage, +--- and the tall narrow zones this happens in have the height to trade. function Components.grid(dst, rect, y, m, rows) local colW = math.floor((rect.w - m.pad) / 2) + if colW < gridMinColW(m, rows) then + local flat = {} + for i = 1, #rows do + for j = 1, #rows[i] do + flat[#flat + 1] = { rows[i][j] } + end + end + rows = flat + colW = rect.w + end for i = 1, #rows do local row = rows[i] for j = 1, #row do @@ -539,16 +576,30 @@ end local function gridRows() return { { - { caption = "PWR", text = Display.powerText }, - { caption = "TQly", text = Display.tqlyText }, + { caption = "PWR", text = Display.powerText, reserve = "2000 mW" }, + { caption = "TQly", text = Display.tqlyText, reserve = "100 %" }, }, { - { caption = "BATT", text = Display.cellText }, - { caption = "TRSS", text = Display.trssText }, + { caption = "BATT", text = Display.cellText, reserve = "4S 3.75 V" }, + { caption = "TRSS", text = Display.trssText, reserve = "-105 dBm" }, }, } end +--- The widest signal formatter availW carries: the diversity pair against the +--- rated floor, the active antenna against it, or the bare reading. The same +--- degradation order the compact tier walks, shared by the rows and the hero +--- that put the signal beside another reading. +local function signalFit(availW) + if availW >= Components.textWidth(SIGNAL_FULL, SMLSIZE) then + return Display.signalText + end + if availW >= Components.textWidth(SIGNAL_SHORT, SMLSIZE) then + return Display.signalShortText + end + return Display.rssiText +end + -- ============================================================================ -- Tiers -- ============================================================================ @@ -571,7 +622,15 @@ function Components.fullTier(w, h, opa, m, spec) local f = Components.frame(w, h, m) local pad, inner = f.pad, f.inner - local gridH = m.sml * 2 + m.gap + -- The grid's height depends on its shape: two columns of two rows where + -- the width holds them, one cell per line where it does not -- so the + -- shape is settled here, before the hero ladder divides what is left. + local rows = gridRows() + local gridLines = 2 + if math.floor((inner.w - m.pad) / 2) < gridMinColW(m, rows) then + gridLines = 4 + end + local gridH = m.sml * gridLines + m.gap * (gridLines - 1) local MIN_BAR = 4 -- Everything except the hero and the two bars. This has to match what the -- blocks below actually consume, or the last row walks off the bottom of @@ -607,7 +666,7 @@ function Components.fullTier(w, h, opa, m, spec) }) y = Components.valueRow(panel, inner, y + air, m, { caption = "RSSI", - text = Display.signalText, + text = signalFit(inner.w - Components.textWidth("RSSI", SMLSIZE) - m.pad), color = Display.detailColor, }) y = Components.bar(panel, inner, y + m.gap, { @@ -618,7 +677,7 @@ function Components.fullTier(w, h, opa, m, spec) -- is worse than no bar. The track stays so the row keeps its height. visible = Display.hasHeadroom, }) - Components.grid(panel, inner, y + air, m, gridRows()) + Components.grid(panel, inner, y + air, m, rows) lvgl.build(root) end @@ -643,10 +702,13 @@ function Components.halfTier(w, h, opa, m, spec) + m.gap -- RSSI row to its bar -- The grid row is whole or absent, and it goes before either bar is - -- squeezed below the height at which it stops reading as a bar. That is - -- the degradation order the whole layout follows: lose a row, keep the - -- instruments. + -- squeezed below the height at which it stops reading as a bar -- or when + -- the zone is too narrow for its two columns; stacked it would eat the + -- bars' height, which is backwards. That is the degradation order the + -- whole layout follows: lose a row, keep the instruments. + local gridRow = { gridRows()[2] } local showGrid = (f.h - fixed - (m.sml + m.gap)) >= MIN_BAR * 2 + and math.floor((inner.w - m.pad) / 2) >= gridMinColW(m, gridRow) if showGrid then fixed = fixed + m.sml + m.gap end @@ -667,7 +729,7 @@ function Components.halfTier(w, h, opa, m, spec) }) y = Components.valueRow(panel, inner, y + air, m, { caption = "RSSI", - text = Display.signalText, + text = signalFit(inner.w - Components.textWidth("RSSI", SMLSIZE) - m.pad), color = Display.detailColor, }) y = Components.bar(panel, inner, y + m.gap, { @@ -677,8 +739,7 @@ function Components.halfTier(w, h, opa, m, spec) visible = Display.hasHeadroom, }) if showGrid then - local rows = gridRows() - Components.grid(panel, inner, y + m.gap, m, { rows[2] }) + Components.grid(panel, inner, y + m.gap, m, gridRow) end lvgl.build(root) @@ -710,10 +771,15 @@ function Components.thirdTier(w, h, opa, m, spec) local panel = Components.panel(root, f.panel, { opacity = opa }) local y = Components.headerRow(panel, inner, pad, m, { detail = true }) + -- What the signal may take is what the hero's number and caption leave. + local rightAvail = inner.w + - Components.textWidth("100", spec.heroFont) + - Components.textWidth("LQ %", SMLSIZE) + - m.pad * 2 y = Components.hero(panel, inner, y + air, m, { font = spec.heroFont, h = heroH, - right = Display.signalText, + right = signalFit(rightAvail), }) Components.bar(panel, inner, y + air, { h = barH, @@ -788,7 +854,7 @@ function Components.compactTier(w, h, opa, m, spec) -- where the row affords it, the bare reading where carrying the floor would -- cost the widget its name -- a decorated half-width zone is exactly that -- wide, and an anonymous row of numbers is worth less than one dBm figure. - local signalW = Components.textWidth("-105 / -105 dBm", SMLSIZE) + local signalW = Components.textWidth(SIGNAL_SHORT, SMLSIZE) local signalText = Display.signalShortText local avail = textRight - textX - signalW - heroW - m.pad * 3 if avail < Components.textWidth(BRAND_SHORT, SMLSIZE) then diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua index 8218245..85207de 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -261,15 +261,28 @@ end --- Everything live rides in closures -- colours and label text -- so the --- cells themselves never move or resize, and presets edited in another --- instance's editor show up without a rebuild. ---- spec.cellH, spec.font and spec.rounded come from the screen file's scale. +--- spec.cellH, spec.font and spec.rounded come from the screen file's scale; +--- spec.w is the width the row has to fit in. --- Returns nil when the presets feature is off or no module is present. function VTXDisplay.buildCells(spec) if not (VTXAdmin.hasModule() and PresetsStorage.enabled) then return nil end + local gap = 4 local font = spec.font or SMLSIZE -- One width for all six, measured against the widest label a cell can carry. local cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + -- A zone too narrow for six content-sized cells squeezes them rather than + -- clipping the row: all six showing outranks their padding, and outranks + -- the tier's cell font too -- the type steps down before the text would. + if spec.w then + local fit = math.floor((spec.w - 5 * gap) / 6) + if font ~= SMLSIZE and fit < cellW and fit < (lcd.sizeText("R8", font)) + 2 * lvgl.PAD_TINY then + font = SMLSIZE + cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + end + cellW = math.min(cellW, math.max(fit, (lcd.sizeText("--", font)))) + end local function isActive(idx) return PresetsStorage.latch.lastPos == idx end @@ -318,7 +331,7 @@ function VTXDisplay.buildCells(spec) borderPad = 0, -- Tighter than the theme paddings: six cells read as one control when the -- gaps between them are beats, not breaks. - flexPad = 4, + flexPad = gap, align = LEFT, visible = VTXAdmin.hasModule, children = cells, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua index 332af1f..85d31bd 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -62,14 +62,15 @@ local function statusLabel() } end ---- The preset cells sized for one tier; cellH overrides the tier's own for a ---- layout that sizes them to its zone. nil when the presets feature is off, ---- same as the builder. -local function cells(f, cellH) +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) return VTXDisplay.buildCells({ cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, }) end @@ -78,7 +79,7 @@ end local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) return rows end @@ -97,7 +98,7 @@ function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f) + local cellRow = cells(f, w) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -150,7 +151,7 @@ function WidgetUI.buildSixth(w, h, opa) -- to a comfortable bubble around the font -- rather than the stacked -- tier's cell height. local fontH = select(2, lcd.sizeText("0", f.cells)) - columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end WidgetLayout.row(w, h, opa, columns) @@ -163,7 +164,7 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) WidgetLayout.column(w, h, opa, rows, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua index e09fca4..cf00b3a 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -61,14 +61,15 @@ local function statusLabel() } end ---- The preset cells sized for one tier; cellH overrides the tier's own for a ---- layout that sizes them to its zone. nil when the presets feature is off, ---- same as the builder. -local function cells(f, cellH) +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) return VTXDisplay.buildCells({ cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, }) end @@ -77,7 +78,7 @@ end local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) return rows end @@ -122,7 +123,7 @@ function WidgetUI.buildSixth(w, h, opa) -- The inline row is the cells' whole zone, so they take its height -- up -- to a comfortable bubble around the font. local fontH = select(2, lcd.sizeText("0", f.cells)) - columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end WidgetLayout.row(w, h, opa, columns) @@ -135,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) WidgetLayout.column(w, h, opa, rows, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua index 1ecf9b3..8e4689d 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -62,14 +62,15 @@ local function statusLabel() } end ---- The preset cells sized for one tier; cellH overrides the tier's own for a ---- layout that sizes them to its zone. nil when the presets feature is off, ---- same as the builder. -local function cells(f, cellH) +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) return VTXDisplay.buildCells({ cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, }) end @@ -78,7 +79,7 @@ end local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) return rows end @@ -97,7 +98,7 @@ function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f) + local cellRow = cells(f, w) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -150,7 +151,7 @@ function WidgetUI.buildSixth(w, h, opa) -- to a comfortable bubble around the font -- rather than the stacked -- tier's cell height. local fontH = select(2, lcd.sizeText("0", f.cells)) - columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end WidgetLayout.row(w, h, opa, columns) @@ -163,7 +164,7 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) WidgetLayout.column(w, h, opa, rows, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua index 03d37b7..cc2cf04 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -64,14 +64,15 @@ local function statusLabel() } end ---- The preset cells sized for one tier; cellH overrides the tier's own for a ---- layout that sizes them to its zone. nil when the presets feature is off, ---- same as the builder. -local function cells(f, cellH) +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) return VTXDisplay.buildCells({ cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, }) end @@ -80,7 +81,7 @@ end local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) return rows end @@ -99,7 +100,7 @@ function WidgetUI.buildSixth(w, h, opa) local bp = WidgetUI.breakpoints local f = WidgetUI.fonts.sixth if h >= bp.sixthStackH and w >= bp.sixthStackW then - local cellRow = cells(f) + local cellRow = cells(f, w) if cellRow then WidgetLayout.column(w, h, opa, { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), @@ -152,7 +153,7 @@ function WidgetUI.buildSixth(w, h, opa) -- to a comfortable bubble around the font -- rather than the stacked -- tier's cell height. local fontH = select(2, lcd.sizeText("0", f.cells)) - columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end WidgetLayout.row(w, h, opa, columns) @@ -165,7 +166,7 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) WidgetLayout.column(w, h, opa, rows, { left = lvgl.PAD_SMALL, right = lvgl.PAD_SMALL, diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua index 725daad..aaa397d 100644 --- a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -61,14 +61,15 @@ local function statusLabel() } end ---- The preset cells sized for one tier; cellH overrides the tier's own for a ---- layout that sizes them to its zone. nil when the presets feature is off, ---- same as the builder. -local function cells(f, cellH) +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) return VTXDisplay.buildCells({ cellH = cellH or f.cellH, font = f.cells, rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, }) end @@ -77,7 +78,7 @@ end local function cardRows(f, w) local heroStatus, hero = VTXDisplay.buildHero(f.hero) local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) return rows end @@ -122,7 +123,7 @@ function WidgetUI.buildSixth(w, h, opa) -- The inline row is the cells' whole zone, so they take its height -- up -- to a comfortable bubble around the font. local fontH = select(2, lcd.sizeText("0", f.cells)) - columns[#columns + 1] = cells(f, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) end WidgetLayout.row(w, h, opa, columns) @@ -135,7 +136,7 @@ function WidgetUI.buildQuarter(w, h, opa) local rows = { VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), } - rows[#rows + 1] = cells(f) + rows[#rows + 1] = cells(f, w) WidgetLayout.column(w, h, opa, rows) end