Skip to content

New LUA scripts & widgets - #3

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

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

Conversation

@jurgelenas

Copy link
Copy Markdown
Member

No description provided.

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.
readLink() was called from inside every LVGL text callback — 18 sites
across 7 files — so a full-screen redraw made 54 getValue() calls and
allocated a table per label. Replace it with Telemetry.link, refilled
once per tick by Telemetry.update() from wgt.background(). EdgeTX runs
refresh() immediately before evaluating every text/color callback, so
labels still read values sampled in their own frame.

Derived state moves into the same update:
- rangePct computed once, so the range colour no longer lags a frame
  behind its text (color callbacks run before text callbacks)
- checkCellCount runs once per tick and regains the original comments;
  cellCntCnt initialises to nil so the (or 0) coalesce is live
- resetConnection() clears cell count, smoothing and diversity on the
  telemetry falling edge, as the original did on RX disconnect —
  without it a 6S->4S swap kept the stale S count

Add a 1/2 tier so the battery row stops being clipped: the ladder went
third (3 rows) straight to full (5 rows), so zones between them lost
the last row. buildHalf draws the four data rows without the title,
gated by a halfH breakpoint sized from the firmware font tables.
portrait needs no rung — its thirdH already clears five rows.

Also dedupe the battery label across the five screen files, drop
topbar's private getRssi copy, and order topbar's hasTelemetry check
before modelMismatch to match statusText().

Rename the simulator mock's RX to Bob 2400RX.
modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it
only means anything while connected. The full-screen banner, topbar's two
colour callbacks and heroColorMismatch() in all five screen files read it
ungated, so a stale flag could paint a label RED while its own text read
"--". Added Telemetry.isMismatch() and used it there; the remaining reads
already sat behind a not-hasTelemetry early return.

heroColorMismatch() was duplicated verbatim across the five screen files and
is now Telemetry.heroColor(); topbar keeps a local mismatchColor() for its
PRIMARY2 base. small.lua's inline RF-mode reader, rfDetailText() and the
full-screen RF Mode row now share Telemetry.rfModeText().

Not reproducible in the simulator: the mock always sets and clears both
flags together.
…bels

WidgetUI.fonts declared hero 20 times across the five screen files and only
three were read; every other hero label hardcoded its font inline. Three
declarations had drifted from what actually renders: hd.full said DBLSIZE
against a MIDSIZE label, sd_tall.third said MIDSIZE against BOLD, and
small.full said MIDSIZE against BOLD.

Every hero label now reads the table, and the three stale entries were
corrected to the value that already rendered, so nothing changes on screen.
Tiers whose hero font depends on statusText() declare a second heroStatus
entry and go through Telemetry.heroFont(tier), which hands back the plain
constant when a tier has no status variant so those labels run no per-frame
callback at all. Two degenerate closures went with it: sd_tall's third-tier
one returned BOLD from both branches and small's full-tier one ignored its
condition entirely.

Verified by building all five screens at every tier from both this and the
parent revision inside one simulator run and diffing the resolved font of
every label: 25 tiers, both the connected and the status branch, zero
differences.
The full-screen telemetry page showed "Active Antenna: 2" while EdgeTX's
own telemetry sensor list showed ANT = 1 for the same link, which reads
like a bug. The numbering was already right and is unchanged: CRSF
defines the field as "enum ant. 1 = 0, ant. 2 = 1" and the sibling
1RSS/2RSS sensors are 1-based, so ANT=0 means antenna 1. EdgeTX simply
prints the raw enum (UNIT_RAW, no offset), as does the TX module's own
OLED, so two of the three places a pilot can look show 0/1.

Prefixing the value with "Ant " stops it being read as that raw number
and keeps the row pointing at the RSSI 1 / RSSI 2 rows above it. Not
renamed to the RX parameter's "Antenna A/B" wording: that would be a
third scheme, and on a dual-radio RX the field reports which radio
decoded the packet rather than a physical antenna.

The two-branch expression also had a catch-all - a nil ANT while
connected rendered "1". It now renders "--", matching RSSI 1, RSSI 2,
TX Power and Satellites in the same block. Telemetry.getRssi and
getRangePct still fall back to antenna 1's RSSI on a nil ant, so the
hero RSSI stays populated while this row admits it doesn't know.

No scenario in the CRSF simulator could reach the antenna 1 branch:
normal pins ANT to 1, and armed pins it to 0 but then the diversity
latch never fires, so it renders N/A. normal now steps ANT through a
fixed sequence, ~5 s per antenna; armed keeps its pinned 0 so the N/A
state stays reachable somewhere.

Verified headless on TX16S (480x272) and Jumper T15 (480x320): Ant 2
and Ant 1 across the toggle, N/A with RSSI 2 greyed under armed, and
-- under no_telemetry.
Telemetry.isDiversity did not mean "diversity RX" - it meant "has
reported antenna 2 at least once since the last connect". A dual-radio
receiver on a healthy link showed "N/A" for the active antenna and a
greyed-out RSSI 2 row until the antenna happened to flip, which on a
stable link may be never, and any failsafe cleared the flag again.

A receiver only writes uplink_RSSI_2 when it has a second RF path: a
dual-radio RX fills it every packet, a switched-antenna RX from its
first switch onward. One with a single antenna never touches it, so it
arrives as 0 dBm - impossible for a real signal. That is the test
ExpressLRS itself uses to decide whether to draw a second RSSI on the
TX module's OLED and TFT screens.

Telemetry.hasDiversity() reads 2RSS from the shared link snapshot,
which Telemetry.update() already samples. The field, the updater, its
call site and the resetConnection() clear all go away, so the state is
derived rather than latched and survives reconnects. Both consumers -
the RSSI 2 greying and the Active Antenna row - now read the one
function and cannot disagree.

This cannot help a switched-antenna RX before its first switch; that
information is not on the wire. ExpressLRS has a trueDiversityAvailable
bit in the OTA link stats, but the TX consumes it locally to drop out
of Gemini mode and never forwards it to the handset.

The simulator had no scenario with a second RF path absent, so the new
single_antenna scenario pins 2RSS to 0. Verified headless on Jumper
T15: single_antenna gives N/A with RSSI 2 greyed, no_telemetry gives
--, normal alternates Ant 1 / Ant 2, and armed - a diversity RX parked
on antenna 1 - is the regression case, previously N/A and now Ant 1
with RSSI 2 live.
The getScreenId() comments and the ui/sd*.lua file headers had the two
480-wide tiers' radios the wrong way round: sd_tall (480x320) claimed
the TX16S family and sd (480x272) claimed the T15 Pro, TX15, ST16 and
PL18. It is the other way round.

radio/src/targets/horus/hal.h selects 480x320 only under RADIO_T15 and
480x272 for everything else in the family, so a TX16S takes the sd
branch. The 480x320 radios live in their own targets - t15pro, tx15,
st16 and pl18 - and are the ones that reach sd_tall.

Comments only, no behaviour change: getScreenId() already routed on
LCD_W/LCD_H and was correct. But the comments are what you read when
deciding which radio to launch to exercise a tier, and they sent you to
the wrong one.
crsf.lua carried two null-terminated string parsers 170 lines apart,
and they were not equivalent: onElrsStatus guarded its loop on
`data[off] and data[off] ~= 0` while the shared CRSF:fieldGetString
guarded only on `~= 0`, so a frame with no terminator hit
string.char(nil). Swapping the call in as-is would have traded a
duplicated loop for a Lua error inside CRSF:poll(). fieldGetString now
carries the nil guard, which also fixes onDeviceInfo - its other
caller, and the one that could actually reach the fault.

onDeviceInfo also committed to the cache before it knew the frame was
long enough: a partial parse set info.name and then errored, and since
requestDeviceInfo() stops pinging once info.name is set, the retry
latched off for good. The version triplet is now checked first.
onElrsStatus gains the source-address check onDeviceInfo and the
tool's parseElrsInfoMessage already had; ExpressLRS hardcodes
CRSF_ADDRESS_CRSF_TRANSMITTER as the 0x2E origin.

Verified headless on TX16S by driving poll() with scripted frames: 21
assertions over both handlers. Reverting just the nil guard failed
exactly four of them and nothing else. The telemetry widget still
renders against the CRSF simulator, so the address check does not
reject the live path.
luaExecStandalone scans the table a TOOLS script returns and then never
pops it, so it sits on the standalone Lua stack - a GC root - for the
whole tool session (colorlcd/standalone_lua.cpp:89-120). The luaL_unref
at line 252 drops the registry reference to init after its single call,
but the lingering table still holds it, and init holds setMock as an
upvalue. Both stayed resident until the tool was closed.

main.lua now returns a named module table so init can clear its own
entry, which subsumes the setMock clear: init uniquely captures
VERSION, useLvgl and setMock, so releasing it releases those too. B&W
and the widget loader both pop the table and are unaffected.

crsf.lua clears setMock for consistency, though nothing captures it
there - it is chunk-scope, so the closure is already unreachable once
the chunk returns CRSF.

Verified headless on TX16S: the tool loads with the mock installed,
re-enters cleanly with no Lua errors, and both widgets still take live
mock telemetry through the shared _crsfSingleton.
Four of the five comment blocks from upstream elrs.lua:437-494 were lost
in the port, leaving a chunk-reassembly state machine over module-level
state with nothing explaining why it does what it does. Restored verbatim
at their original positions, and commented the port-only logic upstream
never had: the device/field mismatch discard, the length sanity check,
the hidden-bit detection that drives fieldHiddenChanged, and the
nameStale/reloading guard on the name cache.

The fifth block described the return value, which is now gone. Nothing
could read it: poll() called the function as a bare statement and
ui/lcd.lua never referenced the parser. Redraw is driven from protocol
state instead -- #loadQueue in lcd.lua:211 and the isFolderLoaded
transition in main.lua:184.
csrfsimulator.lua called table.concat and table.unpack unguarded, and
B&W EdgeTX ships without the table library, so the chunk died at load
with "attempt to index a nil value (global 'table')" and the ExpressLRS
tool could not be exercised on those radios at all. Simulator-only:
setMock() is gated on getVersion() ending in "-simu", so real radios
never loaded the mock.

SCRIPTS/ELRS/shim.lua already carried tableConcat and tableRemove for
exactly this and had no consumer until now; the mock's hand-rolled
tableRemove goes away in favour of it. The unpack site gets a new
charsToString() rather than a tableUnpack polyfill, because a pure-Lua
unpack has to recurse once per element and `return t[i], f(...)` is not
a tail call, so it cannot be optimised away.
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