Skip to content

New LUA scripts & widgets - #3

Open
jurgelenas wants to merge 128 commits into
masterfrom
unified-lua-lsp
Open

New LUA scripts & widgets#3
jurgelenas wants to merge 128 commits into
masterfrom
unified-lua-lsp

Conversation

@jurgelenas

Copy link
Copy Markdown
Member

No description provided.

@CapnBry

CapnBry commented Jun 19, 2026

Copy link
Copy Markdown
Member

I've only gotten through the VTX Admin widget and some of the base code. There's a lot to get through and so much awesome functionality!

VTX Admin

Fix?

  • VTX Admin sending status poll despite never using any info from it
  • VTXA If 6POS changes when the radio boots, and the VTX Admin and the 6POS disagree, 6POS does not push the change because it never technically changed.
    • To reproduce: TX16S Enable 6POS mode, set R1-R6. Select any position except 1 and the VTX changes. Reboot the radio. The 6POS will have returned to position 1 but the VTX stays on what was selected before the reboot. This also occurs on radios with programmable 6POS which have the "Group 1 startup" set to anything besides "=".
  • Includes a presets.txt, which has settings that may not be appropriate
  • VTXA 6POS preset option "Off" shares the same word as VTX Admin = Off, which is confusing because it isn't turning VTX Admin off, it is just an empty unused 6POS preset. Perhaps the editor UI could call it maybeeeeee "Unused". Optionally, it can just be left off when building the 6POS build6posLabels if Unusedseses make it too long, or just shown as a "--" instead.

Minor

  • VTXA ui/topbar and portrait have redundant P- generator

Cosmetic

  • VTXA Status bar: Layout to small "VTX" then R2 under and leave P- off if no power set. Make it look like the other StatusBar Value widgets that are label over value.
  • VTXA P- seems either too terse or too much for what it is. Maybe just don't show it at all or "---"? Actually, if no power is set, then Pit Mode also will never be set (protocol limitation) so this whole line should be left off probably. And if that's the case then the 6POS cheatsheet could use a larger font and take 2 lines (1-3 and 4-6).
  • VTXA ui/topbar should reuse VTXDisplay.showStatus() or bandChannel() for its status
  • VTXA ui has several uses of Protocol.isActive() or VTX.state.band == 0. Candidate for code reuse in loadable since it also uses this as a condition
  • VTXA ui/portrait's difference between its hasModule() and the other state checking functions like isActive(), is there a reason this one is different?

Future

  • Can multiple instances of the widget share a singleton state rather than each one tracking the status themselves and polling? Not a big deal because I don't think users would have more than 2 (statusbar and main widget area), but it seems like a great opportunity to share the same data and just have unique UIs only rather than multiple instances running their own loops and polls.

@CapnBry

CapnBry commented Jun 22, 2026

Copy link
Copy Markdown
Member

I do love that new TOOLS script. Every bit of functionality there seems to do exactly what you want it to and looks good. I haven't had a chance to run through it on B&W yet, but will do that tomorrow.

My major concern on the rest of this is the duplication of code between the tool script and the widgets (which use the common CRSF). That and all the polling that is going on in Telemetry/VTX Admin needs to be removed-- I think it is unacceptable to waste packets for cosmetics. VTX Admin can poll just for the channel change, but should be gated behind like LQ > 90% or something. The rest I think needs to go.

CRSF

Minor

  • onElrsStatus() doesn't use CRSF.fieldGetString()
  • setMock() should be cleared setMock = nil after calling to free memory or make it anonymous like (function() ... end)()

TOOL

Fix?

  • Why does the tool script duplicate crsf.lua and shim.lua?
  • Comments from original parseParameterInfoMessage() removed as if the code there is super easy to follow why it does things :-D
  • parseParameterInfoMessage() return value unused, remove (multiple places)
  • Sort of mixed used of false/nil (e.g. elrsV1Detected, backgroundLoading). nil/true should be used for booleans because it takes no RAM when false. Same with functions. Functions should not return false, they should just return or skip the line entirely if it is the last line and returning false. Lua returns nil if there is no return value so return false is redundant.
  • main.lua should also clear setMock() after init()

Telemetry Widget

Fix?

  • The telemetry widget shouldn't be polling all the time. Polling eats channels frames! I know you've got it rate limited but there is no reason to degrade the link performance for telemetry display.
    • requestDeviceInfo() - Telemetry widget does not ever need to, EdgeTX does this for you, the info will just show up if you're poping CRSF.
    • requestElrsStatus() - hasTelemetry comes from RQly present and > 0. The "model mismatch" is nice but not worth throwing away channels updates for 100% of the time. Perhaps requestElrsStatus() for modelMismatch can be gated in some way to if RSSI > -70 and not modelMismatch and justConnected then updatModelMatch() or something along those lines. But for sure hasTelemetry should not rely on polling, and modelmatch should only be polled when appropriate

Minor

  • Telemetry.cellCntCnt initialize to nil, not 0
  • checkCellCount() comments removed from original
  • Telemetry.readLink being called redundantly for every field instead of having one shared state
  • hd/portrait/sd_tall/sd/small.lua duplicate a very slightly different RxBt for some reason?
  • small.lua also duplicates getRfModeStr()
  • Some places hasTelemetry takes precedence over modelMismatch, sometimes the opposite

Cosmetic

  • The Range % maybe we can get rid of? But would be nice to see something comparing RSSI with the minRSSI
  • Antenna 1 or 2 vs 0 or 1? Very confusing that in one place (Telem list) Ant 0/1 yet another (here) uses different numbering scheme. How about "Ant 1" "Ant 2" like the Lua has it to help distinguish?
  • No TX model or version identifier, e.g. "RM Nomad X-Band (3.6.3)"
  • "No telemetry" vs "No RX Connected"
  • Is just 3 lines of text, LQ, RSS1, and Rate/Power (in tiny text), and battery pops in if it exists? It feels more like a proof of concept listing a few items and most of the font size is for ants

General

  • You mentioned editing the bindphrase with a CRSF string editor. We have the ability to set the bindphrase / UID via MSP and we should not add a string bindphrase field to the parameter list. It should just use the direct MSP. A standalone bindphrase manager to easily swap between them would be cool, but I don't think many would use that. We can rejigger my test lua from the PR to use your backend in another PR.

@CapnBry

CapnBry commented Jun 23, 2026

Copy link
Copy Markdown
Member

And B&W works as well. Here's just a couple more though

  • edgetx.yml - Incorrect repo url ExpressLRS-Lua-Scripts. As a side note, can we also change the repo name to just lua (lowercase, just lua). It is just easier to remember, and all lua scripts are scripts.
  • README.md - States ExpressLRS v3.0+ but the script seems to require v3.5.4 or higher. On v3.5.1 it only shows the Exit line and that's it (on a Zorro)

This concludes my initial review, thank you for your patience!

EDIT: I should also say I love the lazy loading. It is so good I had to verify that it was working correctly with some debug logging on the TX module. Perfect execution to background load on the TX but not the RX for those 50Hz people.

@jurgelenas

Copy link
Copy Markdown
Member Author

@CapnBry thank you for your excellent feedback. I will address it in the upcoming weeks.

@CapnBry

CapnBry commented Jun 25, 2026

Copy link
Copy Markdown
Member

Oops! One more thing. In v4.1.0, the name of the Band item has changed to be more descriptive so we need this or else the widget gets stuck on Loading

diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua
index 92829bf..0c3bb60 100644
--- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua
+++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua
@@ -255,7 +255,7 @@ function Protocol.onSettingsEntry(data)
       Protocol.statusText = "VTX Admin not found"
     end
   elseif st == Protocol.STATE_DISCOVER_VTX then
-    if fieldName == "Band" then
+    if string.sub(fieldName, 1, 4) == "Band" then -- Band or Band/Enabled
       VTX.ids.band = fieldId
     elseif fieldName == "Channel" then
       VTX.ids.channel = fieldId

@CapnBry

CapnBry commented Jun 26, 2026

Copy link
Copy Markdown
Member

crsf.lua defines addresses that I think could also be improved.

  • ADDRESS_HANDSET this is actually our own custom device that we should actually remove, and it should be renamed to ADDRESS_HANDSET_ELRS for now
  • ADDRESS_RADIO_TRANSMITTER is actually the handset and should be relabeled to ADDRESS_HANDSET after the above is changed (this is EdgeTX's official address)
  • Add ADDRESS_RX = 0xEC for future use please
  • ADDRESS_TX_MODULE should probably be just ADDRESS_TX, since there's no confusing this with HANDSET any more and it would match with ADDRESS_RX's style / expected name.

ExpressLRS PR #3608 renamed the Band field to Band/Enable and its Off
option to Disabled. Match both labels during field discovery so the
widget works on firmware before and after the rename, and update the
CRSF simulator to mirror the new firmware parameters.
@jurgelenas

Copy link
Copy Markdown
Member Author

Oops! One more thing. In v4.1.0, the name of the Band item has changed to be more descriptive so we need this or else the widget gets stuck on Loading

diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua
index 92829bf..0c3bb60 100644
--- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua
+++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua
@@ -255,7 +255,7 @@ function Protocol.onSettingsEntry(data)
       Protocol.statusText = "VTX Admin not found"
     end
   elseif st == Protocol.STATE_DISCOVER_VTX then
-    if fieldName == "Band" then
+    if string.sub(fieldName, 1, 4) == "Band" then -- Band or Band/Enabled
       VTX.ids.band = fieldId
     elseif fieldName == "Channel" then
       VTX.ids.channel = fieldId

Fixed. Thanks for the tip!

@jurgelenas

Copy link
Copy Markdown
Member Author

README.md - States ExpressLRS v3.0+ but the script seems to require v3.5.4 or higher. On v3.5.1 it only shows the Exit line and that's it (on a Zorro)

Tested on GX12.

It does not work on 3.5.4, 3.5.5, 3.5.6. Started working with 3.6.0 and above.

@jurgelenas

Copy link
Copy Markdown
Member Author

README.md - States ExpressLRS v3.0+ but the script seems to require v3.5.4 or higher. On v3.5.1 it only shows the Exit line and that's it (on a Zorro)

Tested on GX12.

It does not work on 3.5.4, 3.5.5, 3.5.6. Started working with 3.6.0 and above.

Additional notes: GX12 does not work with firmware below 3.6.0 at all. Created a PR to address min version requirement for GX12:

Our new lua does not work with ExpressLRS below 3.5.4, because the previous versions were not fully CRSF spec compliant. This was addressed in this PR: ExpressLRS/ExpressLRS#3123

@jurgelenas

Copy link
Copy Markdown
Member Author

And B&W works as well. Here's just a couple more though

* edgetx.yml - Incorrect repo url `ExpressLRS-Lua-Scripts`. As a side note, can we also change the repo name to just `lua` (lowercase, just lua). It is just easier to remember, and all lua scripts are scripts.

* README.md - States ExpressLRS v3.0+ but the script seems to require v3.5.4 or higher. On v3.5.1 it only shows the Exit line and that's it (on a Zorro)

This concludes my initial review, thank you for your patience!

EDIT: I should also say I love the lazy loading. It is so good I had to verify that it was working correctly with some debug logging on the TX module. Perfect execution to background load on the TX but not the RX for those 50Hz people.

done

@Ivan125976

Copy link
Copy Markdown

Hello! When running the elrs-bindphrase script on a RadioMaster Boxer (B&W) with EdgeTX 2.11.6, the message "LVGL (EdgeTX 2.11+) required" appears at the top.

The widget requested ELRS_STATUS every 2 seconds by writing field 0,
but nothing under WIDGETS/ELRSVTXAdmin ever read the resulting
hasTelemetry, modelMismatch or elrsFlagsInfo state. The UI is driven
entirely by the folder PARAMETER_READ sent alongside it.

On the TX side that write only triggers an ELRS_STATUS reply; it sets
no mode, arms no timer and raises no device event, so dropping it
changes no behaviour and halves the widget's steady-state traffic. It
also bypassed the CRSF singleton's rate limiter, so with the Telemetry
widget loaded the two sent 1.5 status writes per second instead of 1.
The five per-screen UI files each re-derived the same things locally:
"not Protocol.isActive() or VTX.state.band == 0" appeared on 15 lines
under ui/ and 8 more in loadable.lua, pitModeColor and pitModeText were
defined identically in sd_tall, portrait and small, and the power string
existed in four places.

Add Protocol.hasModule() and VTX.isTuned() for the state checks, and move
every power and pit-mode string into VTXDisplay as powerShort, powerLong,
pitText, pitTextLong, pitShort and pitColor. detailLine and detailLong
now compose those rather than rebuilding them. The screen files drop
their local copies and reference the shared ones directly, which works
because LVGL takes them as bare zero-arg callbacks.

Protocol.hasModule() also names the condition the 6POS cheatsheet was
already using inline: presets come from presets.txt on the radio, not
from the VTX, so they are worth showing before discovery finishes. That
is why it is weaker than isActive(), which was not obvious from the
open-coded comparison portrait had given a local name.

No behaviour change: statusText keeps its three-way branch so the loading
state survives, detailLong keeps returning "VTX Disabled" on band 0, and
pitShort and pitColor keep their guards.
VTX.parseFolderName tested parts[#parts] == "P", but the firmware only
writes "P" when Pitmode is set to On. When it is bound to a switch,
updateFolderName_VtxAdmin writes the aux label instead - "AUX1\192"
through "AUX10\193", where \192 and \193 are the ExpressLRS up and down
arrow glyphs. Every switch-bound configuration therefore displayed
"Pit Mode Off".

Presence of a fourth field, not its text, is what marks pit mode
configured. Parse it as three states: "P" is confirmed on, anything else
is a binding whose switch name is the label minus its one trailing arrow
byte, and no fourth field is off. string.sub does that without a pattern
match, which matters on a path that runs per folder read.

pitmode now means "definitely on" and gains a pitmodeAux companion, so a
binding renders "Pit Mode AUX1" and leaves the display its normal colour
rather than turning red. Red asserts the VTX is muted, and the folder
name carries only the binding, never the live switch position - the
firmware derives that from the channel data, which is not worth a
pattern match here for a value the name never promised.

The CRSF simulator's Pitmode parameter only offered "Off;On" and so
could not reach this path at all; it now carries the firmware's real
option list.
ExpressLRS treats power level 0 as "no change" - luaVtxPwr's options are
literally "-;1;2;3;4;5;6;7;8". updateFolderName_VtxAdmin omits both power
and pit mode from the folder name in that case, and the module hides the
Pitmode field outright:

  // Pit mode can only be sent as part of the power byte
  LUA_FIELD_VISIBLE(luaVtxPit, config.GetVtxPower() != 0);

The widget showed "P-", "Power -" and "Pit Mode Off" anyway, which claims
a pit mode state the protocol has no way to hold. Add VTX.hasPower() and
gate every power and pit string on it, so the line is simply absent until
a level is chosen, and hide the Pit Mode row in the full-screen editor to
match. The row keys on the desired value, so it appears as soon as the
user picks a level rather than waiting for a write to land.

The "P-" and "Power -" fallbacks are then unreachable and are gone, which
is what the redundant-generator review comment was really about.

pitShort and pitColor lose their guards as redundant: after the previous
commit pitmode can only be true when tuned and powered. VTX.isDisabled()
names the remaining band-0 check that detailLong and pitTextLong need,
since those report "VTX Disabled" rather than falling silent.
The top bar rendered one MIDSIZE line, "R2 P2", built from a local
getStatusLine() that duplicated VTXDisplay.bandChannel() and a second
copy of the power string. It also looked nothing like the status bar
widgets beside it.

Stack it instead: "VTX" over the band and channel, two centred SMLSIZE
lines. That matches EdgeTX's own HeaderDateTime, which is the stock
element with this shape, and the sibling ELRSTelemetry top bar. It is
deliberately not ValueWidget, whose FONT(STD) caption over FONT(L) value
comes to 43px of text in a 39px top-bar zone and clips the descenders -
two SMLSIZE lines are 34px in the same space.

Power no longer appears here at all, so the line does not recentre when
a level is set or cleared; the larger tiers still carry it.

Dropping both local helpers leaves the file needing only VTXDisplay, so
the context table the five screen files pass in shrinks to that one key.
Hiding the power and pit mode line frees a row in the minimized tiers,
so the preset cheatsheet no longer has to fit six labels across one line
in a small font. Split it 1-3 over 4-6 and let each screen pick the font
per tier through WidgetUI.fonts.<tier>.cheatsheet, alongside the hero and
detail entries already there.

The split is unconditional rather than tied to whether power is set, so
the cheatsheet does not reflow every time the pilot changes power.

portrait already had exactly this layout as a local buildCheatsheetNarrow
for zones under 200px; promote it to VTXDisplay.buildCheatsheetRows and
drop the copy. buildCheatsheet stays for the sixth and quarter tiers,
which have no spare row. portrait's quarter tier keeps its existing
width test, so that one still picks between the two by zone width.

Fonts are STDSIZE where there is room and SMLSIZE where there is not -
480x272 at the third tier, and every tier on 320x240.
Presets.process() latched Presets.lastPos before calling
Protocol.writeConfig(), which refuses to run outside STATE_READY. At
widget create lastPos is -1, so the first debounced sample was consumed
by the latch and its write discarded; every later tick then returned
early on pos == lastPos. Nothing reached the module until the pilot
physically moved the switch, so the VTX kept whatever band and channel
were last sent to it.

Whether that showed depended on a race. Discovery is round-trip limited,
not timeout limited - onSettingsEntry resets fieldTimeout to 0 on every
response, so the 500ms in fieldResponseTimeout() is only a lost-frame
retry. Roughly sixteen reads can finish inside the 200ms debounce, which
is why the original report did not reproduce on every radio. The same
latch can also drop a switch move made during STATE_SENDING, though that
window is narrower than the debounce and could not be reproduced.

Hold the position until a write can land instead. Since lastPos is -1 on
every create and is never persisted, the first tick after STATE_READY
becomes the boot write, through the same path as any switch move;
writeConfig()'s per-field diff means nothing is sent when the module
already agrees. wgt.background() already runs poll, tick then process in
that order, so syncDesiredFromState() on the READY transition lands
before the preset is applied rather than wiping it.

Nothing is needed for programmable 6POS radios.
DEFAULT_FS_STARTUP_CONFIG is FS_START_PREVIOUS on every switch and
setFSStartupPosition() leaves those alone, so a customizable-switch
group restores its previous position across a reboot by itself.

Verified under the CRSF simulator's slow_loading scenario: before, every
widget instance logged "skipped - not ready" and no write reached the
module; after, none do and the module follows the switch.
VTX.parseFolderName zeroes power and pitmode whenever ExpressLRS omits
them from the folder name, which it does whenever power is "-".
syncDesiredFromState copies those zeros into VTX.desired, and nothing
re-syncs desired after a send completes. A later 6POS change then diffed
the stale zeros against the module's real state and wrote power 0 and
pit mode off, silently undoing both.

Re-base desired on the module's current state before applying the preset
so the 6POS path can only ever write band and channel. Asserting the
position on create makes this reachable in one switch move rather than
two, so it belongs with that change.
Band 0 rendered as "Off" in the preset editor and the cheatsheet, the
same word the VTX Settings band selector uses for a disabled VTX. They
mean different things: one turns the VTX off, the other is an empty
preset slot that leaves the VTX alone.

Show "--" for the preset slot in both places and leave the VTX Settings
row alone, since "Off" is right there and matches VTXDisplay's "VTX Off"
and "VTX Disabled". The two choice lists now differ only in their first
entry, so they stay separate literals rather than being generated from
one table with the label parameterised.

BAND_NAMES existed only to map band 0 to that shared "Off", so it
becomes BAND_LETTERS, a plain 1-based array, and the mixed_table selene
pragma goes with the [0] entry. build6posLabels tests the numeric
sentinel instead of comparing a rendered string. BAND_VALUES stays as it
is: it parses protocol letters, it is not a UI list.

"1:--" is four characters against "1:Off"'s five, so no tier reflows.
processPushSource seeded Presets.pushLastVal to -1 as a "no sample yet"
sentinel, but the edge test was pushLastVal > 0, which turns "unknown"
into "was low". A trigger source already high on the first sample
therefore read as a low-to-high edge and pushed to the VTX with nobody
having touched it.

At widget create that was harmless - pushToVtx() bails as not ready -
but the editor resets the sentinel the moment a source is picked, and
the protocol is ready there. Assigning a two-position switch that
happens to be on, or assigning one while holding a momentary button,
sent an unrequested Send VTx immediately.

Track the level as pushLastHigh, nil until sampled, and have the first
sample adopt it without firing. The -1 was also serving as the
getValue()-returned-nil fallback, which is a different condition; that
now returns without sampling rather than being recorded as low.

Verified with a trigger source that reads high from the first sample:
eleven spurious pushes before, none after.
Send VTx Trigger carries a hint line explaining itself. Auto Push to VTX
sits directly above it, decides whether that trigger does anything at
all, and said nothing. createRow already renders a hint as a SMLSIZE
sub-label, so createToggleRow only needed to forward one.
presets.txt is runtime state the widget writes, and the tracked copy
held one developer's settings - 6POS enabled, auto push on, and raw
source indices that mean different inputs on different radios.
edgetx.yml already excludes it from both pkg install and dev sync so it
never shipped, but it sat in the tree as if it were a default.

The .gitignore entry meant to cover it still named color/WIDGETS, the
path from before the unified-LCD migration, so it had stopped matching
anything. Point it at src/WIDGETS and untrack the file; Presets.load()
falls back to disabled with no source and R1-R6 when it is absent.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants