New LUA scripts & widgets - #3
Conversation
…ns for mk3 and 1 column for small screen radios
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.
Fixed. Thanks for the tip! |
…, ADDRESS_HANDSET_ELRS
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 |
done |
|
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.
No description provided.