From 2aa0384890aabf27e54d80fb16eebb29d86a81a7 Mon Sep 17 00:00:00 2001 From: Julius Jurgelenas Date: Wed, 11 Feb 2026 13:45:15 +0200 Subject: [PATCH 01/81] 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 02/81] 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 03/81] 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 04/81] 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 05/81] 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 zcmb@u2UHYYw=LSJh=7O+sDK0|Cs7cPjG*KsIS8mE$sm~~84xAY1j&e!nw)b`kQ`cK zlY>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 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 06/81] 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 07/81] 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 08/81] 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 09/81] 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 10/81] 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 11/81] 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 12/81] 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 13/81] 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 14/81] 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 15/81] 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 16/81] 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 17/81] 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 18/81] 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 19/81] 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 20/81] 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 21/81] 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 22/81] 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 23/81] 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 24/81] 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 25/81] 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 26/81] 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 27/81] 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 28/81] 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 29/81] 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 30/81] 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 31/81] 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 32/81] 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 33/81] 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 34/81] 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 35/81] 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 36/81] 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 37/81] 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 38/81] 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 39/81] 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 40/81] 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 41/81] 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 42/81] 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 ztnxhLh4|nC<%L%HT$G(VLoYI{OU0|C;^ViEf2^7@m`x|KV-&6R4&(IJ|!|2;c zXT$ti@S=e|##h48*y0=Flf>G+*LIIZqh$lpx0Ya(XC6esdE7{7Y?-~6Xe*=*DFl03J&yZ4;#3Tf4ch~)U`tH zV$g@5{!V)}7H}XCZYV148`d*VjM<*m9v+%oRaRkSxZx>FsO6cSB?3$# zw-0G>$}>b23_12BD^S%=jUppV@GrvJU$9HkOzM zS5Q5HC_e2%++3S;^ILeFxWG6UaBf5PjbLY+*;F$ATfWc1LmjP?g4EIWds=OknpTy} zAvtg9e%l5kX6#~f1Hcq5PfVmYAg=!#w~KU1H@oZpF6A9OYVSzv;0Um&9#kBuf{TV zzi&T$@-1=hgJN3W)l9-)t&u-3hQQtO3XM|vwx1<@#>Gc!*HQ7Vw+iY! zuazy^ODGLinF(1GaH=$w|P(Rrns?<1l>iat@%h&q~= z$oW-og;!RENmrK*hyjHb2HeE6i!8Jil}QV-Pc&NL2_0w8u$ie>>6+j#dc|;W{oP2N z>6%xv-js#+y)EePLXM7$!$|>YwD*`0$I#A`O}SD&GC?g1-@NpQ=daihh0K3%T0^;5 zEw`2|>HPxr3L<0PlOc>nMWE~alyj0Hy-2!HljFM!uDpB-E+Z##g0%;92DlkuWowhX{{YG*ro#ZEG=9yx2Ow=`JDf)NL#tvk zPyVX^eOv?|UXGM0X})ka;2aG184L=>y9~UNyLgJflJ~6F;?1(MwBXn>iwjDy5>p9} z7J8`X@5QyrlIwnJf$x3`cvh?P-%@_fbP~;~P%Ycbs~Ags*+({$K1Pq=G-6I>0TpKd z;-_SS!Rby&TI1zaYu-y`ePtVu`4i&#svNS%Gbd~9nQ#w>yF_;l%uzO6Q=V8##<}eC zyM%}3Utn&qix77^dK>zzUWMdGuDFx|;1e_lXplGlb?{^sUmqih7pd~&o>9z0YJG1L z!3+=*i*kx$Alg#?P4@Ua!jZTRfX-K;%j7*Cwzo5oS>vobY%s3>^|$QSTG)6Ld`}0e zILAe=AFm@^0J8F&B&GUDqXgp|Ph|1c43V;mEc=KVn?wcWfH|MG;czG6Z}AS_0cj$; z4Fgss-Bbld%4~M;%;`bx7^6ZjphWYrU~+&uadVNBGu$b)NsDa9UZ5sEU3F>Q7|zY8N*-~2T0N2X57^!A*7 z(S_?twuN#$IC@4?9gquCwD??lXo z-Ym##ik-;>TPTt{9R0%WVt9{3MvQ@=?^0u=%Z4|k65VSxC3+It)E-AtaJ*~+1J*o_ zY}JOvrT6|><#~Qb=B`TLZPr5(vdk>MjgZf{(0HN7(OsY(38dqqh!|znHDIB9%e;`s?c?p!IFcB)OP4s>rSjo#!aa>@Zd& z)TQ41>QBm}mgu$y99?6CScKVfSj{~t#bPfFVDE8NW z;d#=9!5%rD<52>G8xa6;2a{KiyzAE)-kA)CiL?Yu_Cz5lyfza^8K8UxS(+DVa^huP z99Y^LHAc)o>SS0SSDrku4?0i>c|yaDUdecK=OS90+Q(FmOfKQTW_2l2&^C{zi@WM? zmm-o7D9o8PH-!L*xXDebq2 zA>bmMuU-hsmY2IRd&?jE4Rznqmx_9FEoy#hJ~%J~s3Ju_Kh_JiHkk>2C(ix!)Uflp z?`R5~&p`j2N?&c+97r?1ws+#JU);JMqEVLN8CuiqxCBJz=de3cX`d7G@Us_roHmmZ zp6JkOakB`-!z{VX{}B+G10Zw|1Ts5Yef&YgUW^~WHyPNS{AxC3!gzmD1fkOj-WU%; z1W=LS3;ZW+Dm=td9|XZ5#-ak~^bzbt&Y~>=Ie<-diH6?DBMD|4O9qs?zBXh5)yj32 zM+r86iv(auZHl}wCL-<|X8SWhva0Da#Pl3$$82H6Rs*RL_pjTlp+MstAOj9b9T{&c;wEiHy zGi1L;@K*GY(LpPpIJSY$eto?OelIHK-S>!e0hF(;&nUf3%iYmsYqxVG{qsvE#PTgY zY+MK3lm<&d>IARS7`68G2hGyaPw&_{{H1Xsd(ZxA>BFm#VQ;zS6W%!@fpffQF2S{h zn+GgOJIH*mHz3M~{qvz1zg!1DHuPe1mtA=qKz1WP0$7&tRp(00y9!xzUxG?1Dm0RB z%Ga)_bWdx^BJGq00~ZGqZP{DFGH9lrIOjOUJNY#~^roFRQk!;rn93cau*L zf@ zyVu#6`Lg?JpXG(B538e4i#rQg%InI{T)%=6jKuwilpJzqERyHbUpS_H_YD3}ay&-g zh|^CAw29c25KtW+YEI6?8|s)WcRYpfNz}jMy$vjzlQezebTd5qa;Y*=H`#vQ<6Tqh z@>3xTT#gV(+cXN$CvVmI?Y70_WX7{-g~?XW62Z=(vlc!YWkWruZ5XeiNROvx^UaV5 zq(v`B3D5z+1U6;*R&zmSJR??i`72_ldtG$FU=LUi_wlHnXC5K52#GGQF%WI6@6}lp zMzKtUd?M5*x~ACcgSp#-YH6p{T}M!y;@M8Hi``Osv&fa524)FKYZtnI76F(mNV4!8 zXjP4E0lLPapi)NOTr?s3*r6NYY%Po$yVoZn&+|Uuxx7ROiqb{FY~7t3rjvgJTw`sU zU!RFop=7*D?_IOZ1zDu>wC#9WLVN!xa)^5xaQaAFSjBb~%{9(xQ`g!2_U5fmJ3_XJ@=lkVN zykv1OyN$W?XW{f~A2Axh(-#wxX6;hE&Ol5PWBUPGllDtaZkQP4Ca-|9T;+gb*5<+>X*PK=szp;CJ*_G+WbP&@lN?4vL=gSn^_>se$1lOm$&ztT?|g}Q7@8QxHe|!h~u{<818yp1= zhg6yMwXdC_TK`DgIo{J({`wv1!2m&0=MmSL?#mHQPl z>jZcldG6z&4l=odka7uT7kQzZq-~?VY7hG(oE{%jKK`@aJTbugX-U?1;(_rhk8x#z zLC+VaPu5_H{*s(>gA?zn8r1*@1U5VF{b;gHa=KlDVRMofy_7W{E8BjXGANwcD-!XsKuvc@ucD|UTvVOm4-3oDmR#C?Nf#xI?~*1uk;qI@pjbf;ME>| zf9gq|Ia@ z1Xv7!KqQ15P8azfJq7up#C$xzetC!!n@mdVrH~@^fAd3(O@L;>;qp=QU*^yMy^210 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 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 Date: Tue, 17 Feb 2026 22:00:37 +0200 Subject: [PATCH 43/81] 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 ztnxhLh4|nC<%L%HT$G(VLoYI{OU0|C;^ViEf2^7@m`x|KV-&6R4&(IJ|!|2;c zXT$ti@S=e|##h48*y0=Flf>G+*LIIZqh$lpx0Ya(XC6esdE7{7Y?-~6Xe*=*DFl03J&yZ4;#3Tf4ch~)U`tH zV$g@5{!V)}7H}XCZYV148`d*VjM<*m9v+%oRaRkSxZx>FsO6cSB?3$# zw-0G>$}>b23_12BD^S%=jUppV@GrvJU$9HkOzM zS5Q5HC_e2%++3S;^ILeFxWG6UaBf5PjbLY+*;F$ATfWc1LmjP?g4EIWds=OknpTy} zAvtg9e%l5kX6#~f1Hcq5PfVmYAg=!#w~KU1H@oZpF6A9OYVSzv;0Um&9#kBuf{TV zzi&T$@-1=hgJN3W)l9-)t&u-3hQQtO3XM|vwx1<@#>Gc!*HQ7Vw+iY! zuazy^ODGLinF(1GaH=$w|P(Rrns?<1l>iat@%h&q~= z$oW-og;!RENmrK*hyjHb2HeE6i!8Jil}QV-Pc&NL2_0w8u$ie>>6+j#dc|;W{oP2N z>6%xv-js#+y)EePLXM7$!$|>YwD*`0$I#A`O}SD&GC?g1-@NpQ=daihh0K3%T0^;5 zEw`2|>HPxr3L<0PlOc>nMWE~alyj0Hy-2!HljFM!uDpB-E+Z##g0%;92DlkuWowhX{{YG*ro#ZEG=9yx2Ow=`JDf)NL#tvk zPyVX^eOv?|UXGM0X})ka;2aG184L=>y9~UNyLgJflJ~6F;?1(MwBXn>iwjDy5>p9} z7J8`X@5QyrlIwnJf$x3`cvh?P-%@_fbP~;~P%Ycbs~Ags*+({$K1Pq=G-6I>0TpKd z;-_SS!Rby&TI1zaYu-y`ePtVu`4i&#svNS%Gbd~9nQ#w>yF_;l%uzO6Q=V8##<}eC zyM%}3Utn&qix77^dK>zzUWMdGuDFx|;1e_lXplGlb?{^sUmqih7pd~&o>9z0YJG1L z!3+=*i*kx$Alg#?P4@Ua!jZTRfX-K;%j7*Cwzo5oS>vobY%s3>^|$QSTG)6Ld`}0e zILAe=AFm@^0J8F&B&GUDqXgp|Ph|1c43V;mEc=KVn?wcWfH|MG;czG6Z}AS_0cj$; z4Fgss-Bbld%4~M;%;`bx7^6ZjphWYrU~+&uadVNBGu$b)NsDa9UZ5sEU3F>Q7|zY8N*-~2T0N2X57^!A*7 z(S_?twuN#$IC@4?9gquCwD??lXo z-Ym##ik-;>TPTt{9R0%WVt9{3MvQ@=?^0u=%Z4|k65VSxC3+It)E-AtaJ*~+1J*o_ zY}JOvrT6|><#~Qb=B`TLZPr5(vdk>MjgZf{(0HN7(OsY(38dqqh!|znHDIB9%e;`s?c?p!IFcB)OP4s>rSjo#!aa>@Zd& z)TQ41>QBm}mgu$y99?6CScKVfSj{~t#bPfFVDE8NW z;d#=9!5%rD<52>G8xa6;2a{KiyzAE)-kA)CiL?Yu_Cz5lyfza^8K8UxS(+DVa^huP z99Y^LHAc)o>SS0SSDrku4?0i>c|yaDUdecK=OS90+Q(FmOfKQTW_2l2&^C{zi@WM? zmm-o7D9o8PH-!L*xXDebq2 zA>bmMuU-hsmY2IRd&?jE4Rznqmx_9FEoy#hJ~%J~s3Ju_Kh_JiHkk>2C(ix!)Uflp z?`R5~&p`j2N?&c+97r?1ws+#JU);JMqEVLN8CuiqxCBJz=de3cX`d7G@Us_roHmmZ zp6JkOakB`-!z{VX{}B+G10Zw|1Ts5Yef&YgUW^~WHyPNS{AxC3!gzmD1fkOj-WU%; z1W=LS3;ZW+Dm=td9|XZ5#-ak~^bzbt&Y~>=Ie<-diH6?DBMD|4O9qs?zBXh5)yj32 zM+r86iv(auZHl}wCL-<|X8SWhva0Da#Pl3$$82H6Rs*RL_pjTlp+MstAOj9b9T{&c;wEiHy zGi1L;@K*GY(LpPpIJSY$eto?OelIHK-S>!e0hF(;&nUf3%iYmsYqxVG{qsvE#PTgY zY+MK3lm<&d>IARS7`68G2hGyaPw&_{{H1Xsd(ZxA>BFm#VQ;zS6W%!@fpffQF2S{h zn+GgOJIH*mHz3M~{qvz1zg!1DHuPe1mtA=qKz1WP0$7&tRp(00y9!xzUxG?1Dm0RB z%Ga)_bWdx^BJGq00~ZGqZP{DFGH9lrIOjOUJNY#~^roFRQk!;rn93cau*L zf@ zyVu#6`Lg?JpXG(B538e4i#rQg%InI{T)%=6jKuwilpJzqERyHbUpS_H_YD3}ay&-g zh|^CAw29c25KtW+YEI6?8|s)WcRYpfNz}jMy$vjzlQezebTd5qa;Y*=H`#vQ<6Tqh z@>3xTT#gV(+cXN$CvVmI?Y70_WX7{-g~?XW62Z=(vlc!YWkWruZ5XeiNROvx^UaV5 zq(v`B3D5z+1U6;*R&zmSJR??i`72_ldtG$FU=LUi_wlHnXC5K52#GGQF%WI6@6}lp zMzKtUd?M5*x~ACcgSp#-YH6p{T}M!y;@M8Hi``Osv&fa524)FKYZtnI76F(mNV4!8 zXjP4E0lLPapi)NOTr?s3*r6NYY%Po$yVoZn&+|Uuxx7ROiqb{FY~7t3rjvgJTw`sU zU!RFop=7*D?_IOZ1zDu>wC#9WLVN!xa)^5xaQaAFSjBb~%{9(xQ`g!2_U5fmJ3_XJ@=lkVN zykv1OyN$W?XW{f~A2Axh(-#wxX6;hE&Ol5PWBUPGllDtaZkQP4Ca-|9T;+gb*5<+>X*PK=szp;CJ*_G+WbP&@lN?4vL=gSn^_>se$1lOm$&ztT?|g}Q7@8QxHe|!h~u{<818yp1= zhg6yMwXdC_TK`DgIo{J({`wv1!2m&0=MmSL?#mHQPl z>jZcldG6z&4l=odka7uT7kQzZq-~?VY7hG(oE{%jKK`@aJTbugX-U?1;(_rhk8x#z zLC+VaPu5_H{*s(>gA?zn8r1*@1U5VF{b;gHa=KlDVRMofy_7W{E8BjXGANwcD-!XsKuvc@ucD|UTvVOm4-3oDmR#C?Nf#xI?~*1uk;qI@pjbf;ME>| zf9gq|Ia@ z1Xv7!KqQ15P8azfJq7up#C$xzetC!!n@mdVrH~@^fAd3(O@L;>;qp=QU*^yMy^210 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 44/81] 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 45/81] 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 46/81] 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 47/81] 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 48/81] 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 49/81] 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 50/81] 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 51/81] 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 52/81] 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 53/81] 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 54/81] 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 55/81] 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 56/81] 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 57/81] 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 58/81] 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 59/81] 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 60/81] 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 61/81] 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 62/81] 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 63/81] 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 64/81] 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 65/81] 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 66/81] 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 67/81] 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 68/81] 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 69/81] 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 70/81] 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 71/81] 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 72/81] 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 73/81] 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 74/81] 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 75/81] 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 76/81] 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 77/81] 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 78/81] 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 79/81] 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 80/81] 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 81/81] 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 .