Skip to content

New LUA scripts & widgets - #3

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

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

Conversation

@jurgelenas

Copy link
Copy Markdown
Member

No description provided.

@bd4

bd4 commented Aug 14, 2026

Copy link
Copy Markdown

Just tested the B&W bind phrase script on RM Pocket, worked great. Using it to switch between trainer bindphrase and plane/quad bindphrase.

The module recomputes the flag as it answers and never sends an ELRS_STATUS
unasked, so the one-answer-per-connection latch only reset on a hasTelemetry
edge -- and every way out of a mismatch is applied over a link the handset
never sees drop. The warning stayed on screen for the rest of the session.

The request rules now read as one predicate, canRequestStatus(): connected,
ELRS module, no answer yet or the last one was a mismatch, RSSI above -70, at
most once per second. Only the third rule changed. The RSSI gate still covers
the retry, so a mismatch fixed out at range keeps its banner until the link
drops. ELRSTelemetry create() drops the verdict too: instances are rebuilt per
model, but the singleton they share is not.

The new dev-only mismatch_recovery scenario clears the flag with RQly held at
95, which no existing scenario did. Over 45 s with 5 telemetry + 12 VTX
instances: normal and weak_link still write zero status requests,
mismatch_recovery 11 at 1 Hz and then silence.
crsf_elrsinfo.lua was written on the assumption that TX-module state
would be useful to more than one consumer. It never was: every one of
its five API members has exactly one reader, and that reader is the
telemetry widget. Keeping it in SCRIPTS/ELRS taxed every reader of the
shared library with a module that is not shared, and the library table
already had to say "loaded only by the telemetry widget" to explain it.

Pure path move. It stays a singleton behind the _elrsInfoSingleton
global in main.lua, because its poll rate limits are one set of
counters: loaded per instance it would ping and ask for model match
once per second per placed widget, and every push costs an RC-channels
frame.
The Latitude and Longitude rows on the telemetry widget's full-screen
page have never rendered anything but "--" under the simulator: no
scenario defined a GPS sensor, so Telemetry.gps stayed nil and the one
piece of state the widget deliberately caches across dropouts was the
one piece nothing exercised.

Added to the six scenarios that already carry the rest of the GPS group
(Sats/GSpd/Alt), each with a distinct position so which scenario is
running is readable off the screen. GPS stays out of sensorJitter, like
the other non-moving sensors, so screenshots remain stable.
The packet-rate names and their rated sensitivity floors are 140 lines
of literals that sat between update() and the frame handlers, so
neither the state machine nor the data could be read top to bottom.
They also version on ExpressLRS's release clock, where everything
around them versions on the link-etiquette clock.

RFMOD/RFRSSI stop being public fields of deviceInfo. The +1 that turns
the 0-based RFMD sensor into a 1-based Lua index now lives next to the
literals that define the convention, behind RfModes.name() and
RfModes.floor(), and the "RFMD<n>" fallback with it.

Not a memory saving -- both literal sets stay in select()'s constant
pool. It stays eagerly loaded: a loadScript inside the DEVICE_INFO
handler would put a file open and a compile in a frame handler at the
moment the link comes up.
loadable.lua was four layers in one file and the state it drove was
split across the library boundary, which had duplicated it: the same
tick sampled ANT/1RSS/2RSS twice, picked the active antenna through two
functions that did the same thing, and tracked the connection edge in
two latches. Three restatements of the same status ladder had drifted
apart, and each of the five screen files carried its own byte-identical
detailColor() and heroTextLq() -- so a colour rule could be changed on
four screens out of five.

telemetry.lua now owns everything that describes the link: the drain,
the device cache, the model-match policy, the sensor snapshot and every
value derived from it. It is one owner because all of it describes the
radio, not a widget instance -- two widgets on one screen were two
independently sampled copies that could disagree within a frame.

ui/display.lua is the read model the layouts consume, so the view no
longer reaches past it: crsf is now a vararg of exactly one file in the
widget, which makes the one-drainer rule something grep can check.

Sharing the snapshot needs two guarantees, both in the code and both
stated in the headers. update() samples at most once per tick, or the
range smoother would step once per instance per frame and settle N
times faster on a screen with N widgets; drain() stays per instance and
ungated, because each instance owns a firmware pop queue only it can
empty.

It also needs a reset contract, which replaces one that used to be half
explicit and half accidental. The singleton outlives a model change
where the old per-instance table did not, so resetModel() clears what
belongs to the model -- the GPS position above all, which nothing ever
cleared and which reads as a live fix. It is keyed on the model name
rather than on create() alone, because create() also runs when a widget
is placed, and adding a second telemetry widget must not throw away the
cell count the first one locked on to.

The full-screen page now reads the snapshot instead of sampling five
sensors at paint time, so every row on it is from the same instant.
The page was ~260 lines inline in loadable.lua, which shared nothing
with the wiring around it. loadable.lua is 110 lines now and does what
its name says: pick the layout, wire the components, drive them from
the widget callbacks.

The eight repeated "-- while the link is down" guards collapse into one
whenConnected() wrapper. Which rows take it is deliberate and unchanged:
only the link and power rows. The flight controller and GPS rows keep
their own fallbacks, and latitude/longitude keep showing the last known
position after the link drops.

Loaded on first entry rather than with the widget, since only one
widget can be full screen at a time and update() is not a hot path.

The header describes when EdgeTX actually calls update() -- on
construction, on entering and leaving full screen, on an options edit
-- rather than claiming a rebuild policy the code does not have.
Only one widget can be full screen at a time, so a page builder loaded
with the widget is resident in every instance that never shows one --
and VTX Admin is the widget users place many of. update() is not a hot
path, so waiting until it is asked for costs nothing.

Matches the telemetry widget, so the two keep the same shape.
It sat directly under TX Power showing the same fact in another unit,
and it was not the module's own index: a linear scan over a hardcoded
power table that answered 7 for anything it did not recognise, so a
module with a power level outside the list reported the top index.

TX Power in milliwatts is the number that means something to a pilot.
getFieldInfo returns an ID that encodes a slot in the current model's
sensor list -- api_general.cpp resolves it as
g_model.telemetrySensors[(id - MIXSRC_FIRST_TELEM) / 3] -- but the
cache keyed on the sensor name lives in the CRSF singleton, which is a
global in the widget Lua state and so outlives every model change.

So "RQly" resolved at slot 3 under one model kept addressing slot 3
under the next, where it may be a different sensor entirely, for the
rest of the session. Nothing invalidated it, because until now nothing
in the widget observed the model-change edge.

The telemetry widget now has that edge, in resetModel(), so the cache
is dropped there. Pre-existing; only visible after switching models
with differently ordered sensor lists.
Four things that read as copy-paste divergence rather than intent:

borderPad = 0 on the 1/4 tier's rows was present on sd and hd and
absent on small and sd_tall, with nothing explaining why the same tier
should be padded differently on two screens out of four. Now on all
four. portrait keeps one, because its second row is a bare label rather
than a box.

small.lua read WidgetUI.fonts.<tier>.hero directly where its siblings
go through heroFont(), so a heroStatus added to its font table would
have been silently ignored -- the shrink-while-a-status-shows contract
would not have held on that screen alone.

hd.lua wrote a bare 0 for a font where every sibling writes the name.
It is STDSIZE; VTX Admin's hd.lua had the same wart next to a correct
STDSIZE in the same table, which is what makes it a slip rather than a
choice.

portrait.lua's appendDataRows said it was shared by the 1/2 and 1/1
tiers, but portrait has no halfH breakpoint and no buildHalf.

Verified: sd on TX16S and sd_tall on Jumper T15 both still render.
The architecture section tabled both tools and every library module but
said nothing about the widgets at all, and the README elided their ui/
trees behind a "...". The layering the widgets actually use -- entry
point, domain, wiring, read model, full-screen page, per-screen layouts
-- was only discoverable by reading the loadScript calls.

Records the two rules that hold across both widgets: the per-screen
files are self-contained so shared presentation belongs in
ui/display.lua, and no ui/ file receives the CRSF transport. Also
records what sharing the telemetry state costs, so the next person to
touch drain(), update() or a frame handler knows which of the three
invariants they are standing on.
The rate tables carry a literal 0 for the rates ExpressLRS publishes no
sensitivity figure for, and RfModes.floor returned it unchanged. Its own
docstring says otherwise, and so does every caller: telemetry.lua guards
with `or -128`, which 0 -- a truthy number -- walks straight past. The
range percentage then divided by (0 + 50) on nine of the v4 rates and two
of the v3 ones.
Range % answered a question nobody asks: it counted *down* from a full
bar as the link got better, and it was a percentage of a distance that
was never measured. What a pilot wants is how much signal is left before
the receiver stops hearing, and ExpressLRS publishes exactly that -- a
rated sensitivity floor per packet rate, already in rf_modes.lua.

So the smoother now produces headroomPct with the polarity the right way
up, the detail line prints the pair the comparison needs (-93 / -108 dBm)
instead of an invented percentage, and its warning colour is driven by
link margin in dB -- the unit the number is actually measured in -- on
the same thresholds range % used. The full-screen page gains Sensitivity
and Link Margin rows directly under the RSSI ones, sharing their unit so
the arithmetic between the three is visible.

Also samples TQly and TRSS, the downlink pair, and promotes the status
ladder out of Display.statusText() into Telemetry.statusLevel(), so the
words and the colours to come index one value rather than two copies of
the same if-chain.
TQly and TRSS are the RX->TX telemetry path, and nothing could exercise
them because no scenario served them. They sit a few dB behind the uplink
everywhere, which is the asymmetry worth showing: the receiver answers at
a fraction of the module's power.

The unrated_rate scenario reaches the other untested branch. Every
connected scenario ran RFMD 7, which is rated, so the fallback for a
packet rate ExpressLRS publishes no sensitivity figure for was
unreachable in the simulator -- which is how RfModes.floor came to return
0 for it unnoticed.
The minimized widget was four left-aligned labels on a flat rectangle,
with no bar, gauge, rule or indicator anywhere in src/WIDGETS. This gives
the 1/1 tier the instruments the data has always deserved: a status LED,
the uplink as a headline LQ over two full-width bars, a stepped TX power
meter, antenna cells, and a captioned row for the downlink and battery.

The uplink is deliberately unheadered. It is what you fly on -- a
degraded uplink loses control where a degraded downlink only makes the
numbers stale -- so it takes the upper body in a register nothing else
competes with, and the downlink is one captioned row underneath.

ui/components.lua carries the drawing and the composition, so each screen
file's buildFull is the four lines that differ: the hero font and the
width gate. Everything is label, rectangle and circle, the only types
lvgl.build() will accept in a widget zone, and nothing calls build()
until the tree is complete.

Two things measured rather than assumed. Font line heights come from
lcd.sizeText, which still works under LVGL, because the 800x480 target
ships different fonts and a scaled 480x272 number would be wrong there.
And whether the downlink and battery groups share a row is decided by
their measured widths, not a breakpoint -- on the same TX16S the answer
differs between a 396px zone and a 198px one, and the ladder takes the
dBm unit off first, then the pack total, then splits the rows, and only
then drops the DOWNLINK header, which it can do at all because TQly and
TRSS name themselves.
GREEN and RED are raw primaries -- RGB(0,255,0) and RGB(255,0,0),
colors.cpp:51-54 -- so a full-width LQ bar in GREEN was the loudest thing
on the screen, and it stayed that way whatever theme the pilot had
chosen. The theme carries the same three meanings in colours picked to
sit together: EDIT, ACTIVE and WARNING. Bars, the status LED and the LQ
headline now take those and repaint with the theme.

Text keeps a separate ramp. COLOR_THEME_ACTIVE is a bright yellow, which
is fine as a bar fill and illegible as a word on the light themes'
near-white panel, so warnings in text stay ORANGE.

Bar tracks are the disabled grey at part opacity. Solid it read as a
second bar competing with its own fill; the theme's soft secondary
disappeared into the panel entirely, which left the fill a floating stub
with nothing to measure it against.

The 1/2 tier keeps both bars, which is the point of the layout -- it is
the size the widget is usually placed at, and the mirrored-blocks draft
this replaced could not keep even one there. The battery row drops before
either bar is squeezed below the height at which it stops being a bar.
TQly sits beside TRSS on the same row in percent, so a bare -94 there
invites reading it as one too. The unit is not something the width ladder
gets to trade away.

It was the first thing to go, which left the narrowest rung showing
"TRSS -94". Now the pack total goes first, then the row splits, and the
DOWNLINK header is what drops last -- the right order, because TQly and
TRSS name themselves and a bare number does not name its unit.

Costs nothing at the sizes that matter: the 396px zone still fits the row
under its header, and the 198px one reaches the headerless rung it was
already using.
Both were COLOR_THEME_PRIMARY1, which is the text colour -- RGB(0,0,0) on
the light themes. As a glyph that is correct and as a filled block it is a
black bar dropped onto a blue panel, which is what they looked like.

COLOR_THEME_FOCUS is what EdgeTX uses everywhere else to mean "this one is
active", which is exactly what a lit power cell and the live antenna cell
are saying, and it follows the theme.

Power deliberately stays off the green/amber/red ramp: 500 mW is not
worse than 50, so a health colour there would be claiming something the
number does not mean.

The mismatch banners move from RED to COLOR_THEME_WARNING with them, so
the whole widget now draws from the theme. The single exception is ORANGE
for warning text, because COLOR_THEME_ACTIVE is a yellow that cannot be
read on a near-white panel.
Four adjustments from looking at it on the radio rather than in the plan.

The status LED was centred on the strip height, which is the text's line
box plus padding, so it sat below the RF mode beside it -- near enough to
read as a mistake rather than a choice. It now centres on the line box,
landing on the same centre as the antenna cells, and it is a third
smaller: at half the row height an indicator stops being a dot and starts
competing with the text. It also gets a fixed lane, so its radius can be
tuned without the RF mode moving sideways.

TX POWER's meter was absorbing the whole gap between the RF mode and the
antenna cells, which dragged its own reading to the far side of the strip
with nothing tying the two together. The meter now has a natural width
measured off the row, the reading sits a fixed gap after it, and the slack
stays to their right. Whether the meter fits at all is now measured
against the antenna cells' position instead of assumed from the width
gate.

The panel is inset from the zone rather than filling it, so two widgets
side by side no longer share one edge, and the content is inset again from
the panel's own border -- full-width bars starting and ending on the
border read as spilling out of the panel.

And at half width TX POWER moves above the uplink panel instead of below
the group rows. It is link configuration, like the RF mode and the antenna
it now sits with; last place filed it under the flight controller's
readings, which is not what it is.
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.

4 participants