Skip to content

New LUA scripts & widgets - #3

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

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

Conversation

@jurgelenas

Copy link
Copy Markdown
Member

No description provided.

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.
The tool script carried ~90 lines of verbatim or near-verbatim copies of
SCRIPTS/ELRS/crsf.lua and shim.lua: the address/frame-type/field-type
constants down to identical comments, pop/push, hasCrsfModule, setMock,
and a byte-identical tableConcat polyfill. The copies had already
drifted into a bug: crsf.lua declared CMD_CONFIRMED = 3 where the
firmware's commandStep_e has lcsAskConfirm = 3, lcsConfirmed = 4 --
latent only because the widgets never send anything past CMD_CLICK.

The tool now loads /SCRIPTS/ELRS/crsf.lua and shim.lua and references
crsf.CONST directly at every call site; its own shim.lua is deleted and
protocol.lua drops its constant table, pop/push and hasCrsfModule.
main.lua's setMock goes too -- crsf.lua already self-mocks at load, and
the tool talks to CRSF only through the library. The library gains what
the tool needs (FIELD_UINT32..INT64, ELRS_SERIAL_ID, ELRS_FLAGS_*) plus
the corrected command steps. B&W radios ship without the table library
(linit.c gates it on COLORLCD), so crsf.lua's two raw table.* calls now
go through the shim -- a prerequisite, since the tool is the first
consumer of this code on B&W hardware.

Checking the internal pseudo field types against the CRSF spec showed
DEVICE = 15 shadowed a real wire type: 0x0F is CRSF_VTX in both the
spec and the firmware enum, so a TBS VTX browsed via Other Devices
would have rendered its VTX parameters through the device-row handler.
The synthetic markers are now 128/129 -- provably unreachable from the
wire, whose type byte is masked with 0x7f -- FIELD_VTX = 15 documents
the wire truth, and the unreferenced BACK_EXIT (legacy elrs.lua's
synthetic EXIT row) is gone. shim.getSensorValue is deleted as a third
copy of crsf.getSensorValue with no consumers, and edgetx.yml marks the
tool as depending on the ELRS library, matching the widgets.

Verified headless on GX12 (B&W) and TX16S: the tool loads and populates
the root folder on both, switches to the mock RX through Other Devices,
and drives Enable WiFi through ASKCONFIRM -> CONFIRMED -> Executing --
the path the drifted constant would have wedged, since the mock only
advances on 4. no_module, slow_loading and model_mismatch scenarios,
and both widgets running live alongside the tool, all render correctly.
hasTelemetry now derives from RQly instead of a 1 Hz status poll, and
the ELRS status request is sent once per connection (connected + ELRS
serial + active-antenna RSSI above -70 dBm), just for model match. The
device ping goes to the TX module instead of broadcast -- answered on
the UART, never over the air -- via the shared CRSF:pingDevices(dest).
VTX Admin loses its periodic folder poll entirely: reads happen at
discovery, after our own writes (retried up to 3x), and once on resume
from a tool session. Net widget traffic is ~2 frames per session where
it was 1-2 per second forever.

The CRSF mock honors ping destinations, logs every push, and gains
weak_link and mismatch_cycle scenarios; model_mismatch RSSI is raised
to a bench-realistic -55/-58 so the status-request gate can pass.
App.reset() and Protocol.reset() had no callers and never did -- both
arrived orphaned in the BW/colour unification commit. CRSF's
unregisterHandler() goes with them, along with registerHandler's
duplicate-registration guard: every registration site is a top-level
once-per-chunk statement, and the one repeat path (both widgets sharing
_crsfSingleton) deliberately registers a fresh closure per instance, so
the guard could never match. onDeviceInfo's info.vStr was a
string.format on every DEVICE_INFO frame that nothing read.

Protocol.handlers loses its constructor placeholder and its save column,
which no caller ever dereferenced -- both UIs call fieldIntSave,
fieldStringSave and handleCommandSave directly, from code that has
already branched on the field type. Entries are plain load functions
now; the four typed nil entries stay, documenting the wire types with no
handler. The data[i] = nil writes in parseParameterInfoMessage went too:
the frame table is fresh per pop and the tool is its only consumer.

VERSION_CHECK_ENABLED was constant true in both UIs, so init() runs the
version check unconditionally and preCheck tests the result. lvgl's
buildFieldWidget drops its folderWidth parameter and FIELD_FOLDER
branch, unreachable because the build loop consumes every folder run
before reaching it. lcd's init() stops re-assigning its declared
defaults, and the count == 0 guards in selectField and handleEvent are
gone -- getSelectableCount() is getFieldCount() + 1 for the
always-present BACK/EXIT row.

warningDismissed folds into warningDismissedAt in both UIs; the boolean
equalled "timestamp is set" at every observable point. The lvgl Exit
callback's flag-only write is dropped rather than timestamped, since
handleWarning early-returns on App.shouldExit and never read it.

ELRSVTXAdmin stops passing crsf, Presets and Protocol to the screen
files, which never used them, and the orphaned Protocol and VTX locals
go with them -- portrait keeps VTX, being the one tier that renders
power and pit state. writeConfig's boolean pitmode coercion is
unreachable: d.pitmode is a number on every path.

Verified in the simulator on TX16S and GX12 -- the tool loads and
renders, folder navigation and a field edit work, and the model-mismatch
warning shows, dismisses, stays suppressed at 30 s and returns after
60 s.
crsf.lua is a transport core again: constants, the pop/push fan-out and
sensor reads. The DEVICE_INFO cache, the RFMOD/RFRSSI tables and the
per-connection model-match latch move to WIDGETS/ELRSTelemetry/txinfo.lua,
loaded only by the widget that reads them, so the tool and VTX Admin no
longer pay for them.
decodeDeviceInfo(), decodeElrsStatus() and isElrsV1Frame() own the wire
layout of DEVICE_INFO, ELRS_STATUS and the 1.x signature; callers gate on
the decoded source id. The status decode keeps the connected bit alongside
modelMismatch and criticalError.

The tool's protocol.lua consumes them instead of carrying its own parsers
and bit masks, stores the decoded flags, gains a short-frame guard its
DEVICE_INFO path lacked, and sends the link-status request through
requestElrsStatus(), whose hardcoded addressing the deviceIsELRS_TX gate
already guarantees.
elrsinfo.lua is the opt-in stateful companion to the CRSF singleton --
DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection
model-match latch -- registering its handlers on the shared singleton
and consuming the shared decoders instead of parsing frames itself.
Loaded only by the telemetry widget, so the tool and VTX Admin never
pay for it.

The RF tables now build once per detected major version instead of on
every DEVICE_INFO frame, and the highest known version at or below vMaj
wins, so ELRS 5 firmware keeps the newest rate names instead of losing
them.
encodeParameterEntry now slices entry payloads at maxPacketBytes - 8
exactly as CRSFEndpoint::sendParameter does (6 bytes CRSF header/CRC
plus the FieldId/ChunksRemain pair repeated per frame), honouring the
requested chunk index it previously ignored. config.maxPacketBytes
defaults to CRSF_MAX_PACKET_LEN (64); lowering it emulates a slow-baud
handset (CRSFHandset::adjustMaxPacketSize) for deeper chunking.

Also adds the coverage the mock was missing:
- an INT8 "RF Gain" field (value -3, min -10, max 10) on the TX device,
  plus INT8 two's-complement decode on write, so sign extension is
  exercised end to end
- a "critical_error" scenario (connected + baud-rate error, flags 0x41)
  whose critical bits clear on the suppress-critical-errors write
  (pseudo-field FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E, the bare
  literal TXModuleEndpoint.cpp matches on)

Verified on TX16S under scenario "normal": a full TX+RX parameter walk
through the shipping tool protocol decodes identically to the layout,
with Pitmode (3 chunks) and Packet Rate (2 chunks) reassembled complete
and RF Gain read back as -3/-10/10.
src/SCRIPTS/ELRS/crsf_fields.lua now owns the stateless parameter-field
codecs: the byte getters (readValue, readStringOrOpts) and decodeEntry,
which masks the type byte, reads the hidden bit and name, and dispatches
the per-type loaders (int with sign/width arithmetic, float, text
selection with the dirty/identity cache, string, folder, command). Like
elrsinfo.lua it is opt-in: telemetry-only widgets never load it. Unlike
crsf.lua's fieldGetString, nothing in it mutates the frame data table,
so it is safe under the poll() handler fan-out.

protocol.lua keeps the policy that was interleaved with the decode:
the load-queue pop, the fieldHiddenChanged notification, the CMD_IDLE
popup-clear + related-fields reload (formerly inside fieldCommandLoad),
and the root/background child auto-queue. Its chunk reassembly is
unchanged. The tool no longer loads shim.lua -- its only uses moved
with the codecs.

Verified: full TX+RX field dumps through the tool protocol are
byte-identical to the pre-move baseline on TX16S and on GX12 (whose Lua
state has no table library, proving the shim discipline holds).
crsf_fields.lua gains the session-addressed frame senders: sendWriteInt
(two's-complement re-encode at the field's width), sendWriteString
(maxlen clamp, NUL-strip, terminator), sendCommandStep (the one-byte
commandStep_e write behind CLICK/CONFIRMED/CANCEL/QUERY) and
sendSuppressCriticalErrors (pseudo-field 0x2E, the bare literal
TXModuleEndpoint.cpp matches on). Each reads deviceId/handsetId from a
caller-owned session table -- the tool passes its Protocol table.

The UIs receive the codec via deps and call the two pure save
operations directly; everything with policy stays behind protocol.lua:
handleCommandSave, commandConfirm, commandCancel (send + dismiss), the
new commandRequestCancel (send + keep the popup for the device's
CMD_IDLE echo) and suppressCriticalErrors (optimistic local flag clear
+ the suppress write).

lcd.lua no longer builds frames: the warning screen calls
suppressCriticalErrors(), and drawPopup's EXIT handling is routed per
status -- ASKCONFIRM/EXECUTING through commandCancel() exactly like the
colour UI, everything else through commandRequestCancel(). The one
deliberate wire change: EXIT during an executing command sends a single
CMD_CANCEL where the old code pushed it twice (once at the top of
drawPopup, again via popupConfirmation's CANCEL result).

Verified on TX16S: RF Gain -3 -> -4 edit round-trips (negative encode,
mock decode, sign-extended read-back), a Packet Rate change reloads
siblings (Telem Ratio units recompute), Bind runs CLICK -> 4 QUERYs ->
natural completion with the popup dismissed by the IDLE echo, and
Enable WiFi runs ASKCONFIRM -> CONFIRMED -> EXECUTING -> one CANCEL.
On GX12: the critical-error banner clears with the 0x2E write on the
wire, and EXIT during Bind's executing popup sends CLICK + 2 QUERYs +
exactly one CANCEL with no error.
crsf_fields.lua gains reassemble()/resetChunks(), a line-for-line port
of the tool's PARAMETER_SETTINGS_ENTRY chunk handling operating on
session-table state (fieldChunk, fieldData, expectChunksRemain), and
sendRead(), which carries session.fieldChunk so follow-up reads of a
chunked entry continue where reassembly left off.

Two deliberate deltas from the old inline code, both inert for the
tool: reassemble() takes an explicit expectedFieldId so a passive
consumer under the crsf:poll() fan-out can pass data[3] and accept any
field from its device, and a new fieldDataId gate keeps a
sibling-elicited entry for another field out of an in-flight buffer --
required for that fan-out mode, unreachable when the expected id is
pinned. The unallocated-field guard moved from before-buffering to the
caller's decode gate (unreachable either way: the expected id always
comes from loadQueue/fieldPopup, both within the allocated range).

protocol.lua keeps the policy wrapper: expected-id selection from
popup/queue, queue pop on completion, decode via decodeEntry, and the
reload/auto-queue rules. Its manual chunk-state clears became
resetChunks().

Verified: field dumps byte-identical to the pre-refactor baseline on
TX16S (maxPacketBytes 64 and 22 -- 14-byte chunks splitting names
mid-string) and GX12; slow_loading shows the loading gauge progressing
through the 0.5 s retry cadence against 2 s responses with no errors.
The widget's private parse helpers are gone: parseChildIds and
parseFieldName read at hardcoded offsets 6/7 and parseFieldType dropped
the hidden bit, all on the assumption that an entry always arrives in
one frame. Discovery now runs Protocol (its session table, addressed at
ADDRESS_TX/ADDRESS_HANDSET_ELRS) through crsf_fields reassemble +
decodeEntry, and reads go out via sendRead, which carries the chunk
index the old sendParameterRead pinned to 0 -- so chunked entries work
without a new loop: an incomplete entry leaves the item queued (or the
folder read armed) and the existing tick retry sends the follow-up.
discoveredFields, previously write-only, is now the decode-target cache
that gives decodeEntry its per-field identity.

Reassembly gained a guard the tool never needed. Under the poll()
fan-out every instance sees every answer, so when several instances
each request the same field, the extra copies of a multi-chunk entry's
final chunk arrive after a session has already completed it -- and
their header is indistinguishable from a fresh single-frame entry, so
they decoded as garbage. Reproduced with two widget zones at
maxPacketBytes 22: both read "VTX Off" instead of "R3". reassemble()
now records the field whose chunked entry just completed and swallows
those trailing finals until the next request cycle.

VTX.parseFolderName strips the pit-mode aux arrow by suffix: the shared
decoder translates the firmware's one-byte 0xC0/0xC1 into the
multi-byte CHAR_UP/CHAR_DOWN glyphs, which the old fixed -2 slice would
have cut mid-glyph. Write-queue entries carry {id, value} so
sendWriteInt consumes them directly; "Send VTx" queues CMD_CLICK the
same way, byte-identical on the wire.

Verified on TX16S with two widget instances: discovery to READY, a Band
R->A change writing field 11 and both instances picking up the folder
read-back, Send VTx sending one CMD_CLICK, and the resume-from-tool
path issuing its single folder re-read. At maxPacketBytes 22 both
instances now show R3, and the tool's field dump stays byte-identical
to the baseline.
elrsinfo.lua becomes crsf_elrsinfo.lua, so SCRIPTS/ELRS holds crsf.lua,
crsf_fields.lua and crsf_elrsinfo.lua under one prefix, with shim.lua
as the only non-CRSF file. The singleton global (_elrsInfoSingleton)
and the module table are unchanged.

Verified: the telemetry widget loads /SCRIPTS/ELRS/crsf_elrsinfo.lua
and renders link stats on TX16S.
hasTelemetry(), isModelMismatch() and hasCriticalError() only returned
Protocol.connected / .modelMismatch / .criticalError, which the UIs and
main.lua already read as plain fields elsewhere. The call sites now use
those fields; a caller that needs a function-valued property (an LVGL
text/visible callback) can close over them at the point of use.

Verified on TX16S: the model_mismatch scenario still raises its dialog
and the subtitle still reports telemetry state.
Adds crsf_fields.lua to the shared-library table with the non-mutating
rule and the two receive models reassemble() serves, records the
protocol.lua row as policy over that engine, and lists the new
critical_error scenario and the maxPacketBytes chunking knob. README's
SD card tree picks up both renamed and new library files.
Commit f04caf2 replaced the per-type save wrappers with the codec's
sendWriteInt, but the B&W edit path kept calling Protocol.fieldIntSave,
so committing any edited value crashed the tool with "attempt to call
a nil value". Both numeric and text-selection edits go over
sendWriteInt, matching the LVGL UI.
poll() hands one data table to every handler registered for a frame
type, so the decoders must not write into it. The string reader now
collects chars into its own buffer instead of converting frame bytes
in place, which lets any handler decode the same frame independently
of registration order.
crsf_params.lua is a pure codec: reassembly runs over an explicit
caller-owned rx table ({ chunk, data, dataId, expect, done }) with the
device address passed in, and the frame builders return
(frameType, payload) for the caller to push instead of pushing
themselves. Callers no longer lend the codec a slice of their own
table -- the six flat reassembly keys and the addressing reads are
gone, and every send site is crsf.push(encode*(...)).
crsf_session.lua is a stateful CRSF parameter client (CRSFSession.new,
multi-instance): it owns the field store, the load queue, the paced
write queue, the command state machine, and optionally device
discovery, link status and ELRS 1.x detection. The overloaded
fieldTimeout becomes named per-purpose deadlines, and tick() documents
the send ladder it always implied: at most one parameter frame per
tick, command keep-alive > write drain > link-status > reads.

protocol.lua dissolves: the session absorbs its machinery, main.lua's
App keeps the policy residue (device switching, folder-ready edges,
the synthetic "Other Devices" row types), and both UIs talk to the
session surface -- writes go through writeField, which encodes by
field type and re-reads the related fields itself, so the codec no
longer appears in any UI.

Two deliberate hardenings over the old engine: the load-queue head
pops only when the decoded entry answers it, and CMD_IDLE dismisses
the popup only for the active command.
Each widget instance owns a CRSFSession in passive fan-out mode
(acceptUnsolicited): discovery re-derives from the session's field
store through the onFieldUpdate callback, writes go through the paced
write queue (STATE_SENDING exits when isWriting() clears), and the
bounded folder read-back is the session's refresh slot. The widget
keeps only its state machine, statusText and write policy.

Two session rules surfaced by running many instances on one bus: a
fan-out session must decode folder names fresh (no staleness flag can
cover a sibling's write rewriting the name -- non-folder names stay
cached, they are static), and the root-children auto-queue must only
trigger off the session's own read, or every sibling's root answer
multiplies the load traffic by the instance count.
Merge the widget's VTX and Protocol tables into a single VTXAdmin
component: the client of the ELRS "VTX Administrator" service, owning
the discovery state machine, current/desired VTX state, write policy,
and the 6POS quick-change and push-trigger automation behind one
tick(). The machine state is now "phase" -- VTXAdmin.state is the
parsed VTX state.

Around it, loadable.lua wires focused components: presets_storage.lua
is a pure settings store (preset slots, sources, flags, and their
key=value persistence), ui/display.lua carries the VTXDisplay
read-model and WidgetLayout builders, ui/fullscreen.lua the
full-screen editor, and the schema-free key=value file I/O joins the
shared ELRS library as file_storage.lua. Dependencies run one way:
FileStorage <- PresetsStorage <- VTXAdmin <- UI.

Behavior is unchanged: log lines, the hold-until-ready 6POS latch, the
push-trigger first-sample adoption and the presets.txt format all
survive verbatim, verified in the simulator against a pre-refactor
baseline (discovery, 6POS sweep, trigger edges, save/load roundtrip,
no-module, 11 instances).

The CRSF simulator mock rides along: it now builds folder-name
summaries only for the TX module and mimics the firmware's Fan Thresh
visibility rule (hidden while Dynamic power is off).
Six collections of six band/channel presets, stored as one line per
collection in presets.txt and selected from a dropdown in the
full-screen editor. The dropdown drives both what the 6POS switch
applies and what the preset rows below edit; picking a collection
re-applies the parked switch position without touching the switch.

The preset store becomes a per-radio singleton shared by every widget
instance, and the 6POS and push-trigger state moves onto it: an edge --
a switch move, a collection switch, a push trigger -- is consumed by
the first instance whose session can act, so it produces exactly one
write per radio instead of one per instance. The push trigger
re-latches when its source is reassigned, adopting the new source's
level without a phantom push.

The full-screen page is built once on entry and never rebuilt; values
that change under it reach the screen through per-frame get()
callbacks, so focus and scroll survive a collection switch. numberEdit
re-polls get() only on EdgeTX 2.12.0+, so on older firmware the
channel and power digits catch up when the page is next entered.
A bare .lua install ran out of memory opening the tool on the GX12:
EdgeTX compiles each file to bytecode on first load, no GC runs
between loadScript calls, and the seven-file chain stacks its parse
peaks until the heap is gone. Later launches worked because every
attempt caches a few more .luac files. main.lua now loads through
loadPart(), which collects garbage before each load, and the parse
path itself gets smaller.

Frames are now consumed pull-style everywhere. The firmware
replicates every incoming frame into each widget instance's private
queue and pop() reads the queue of the instance whose code is
running, so each consumer drains its own replica through
crsf.drain(consumer, onFrame) -- the tool and VTX Admin through
their session, the telemetry widget through elrsinfo. The handler
registry, poll() and attachBus() go away, and with them the tick
guard under which only one instance's queue was popped per tick
while the others accumulated stale backlog. hasTelemetry derives
inside pop() as a drain empties, so frames always land before a
consumer can observe the flip.

Decoding sheds its allocation churn: the link-layer readString
converts bytes to chars in place (each frame type has exactly one
string-decoding consumer per instance), and the parameter codec
refills a text selection's caller-owned values table in place,
bumping field.valuesRev for the LVGL UI to watch instead of table
identity. Folder-name suffix stripping moves into ui/lvgl.lua, its
only consumer.

The generic telemetry reader moves to sensors.lua; crsf.lua loads it
and exposes crsf.getSensorValue, the seam setMock() patches exactly
like pop and push. shim.lua slims to the tableConcat polyfill, the
simulator carries its own shim, and reloading at the tool's root
goes through session:discoverDevices() instead of raw pings.

The CRSF mock now mirrors the firmware's replication: frames append
to a shared log and every consumer advances its own cursor,
receiving a fresh copy per delivery. Verified in the simulator on
TX16S (both VTX Admin instances, telemetry widget, choice edits
with sibling reload bursts) and GX12 (full load chain, edits,
root rediscovery).
The title flash ran free: it was never re-anchored when the warning
flags changed, so a fresh warning could wait a full flash period for
its visible half (and start on the hidden one), and a cleared warning
could linger to finish its cycle. Latch the flags byte and restart
the flash on any change -- a new warning now starts visible on the
frame its status answer lands, and a cleared one drops immediately.
This restores the re-anchor the rewrite dropped from elrsV3.lua.

In the CRSF simulator, drive the armed flag from CH5 (AUX1, the ELRS
arm channel) the way the firmware samples handset->IsArmed(): every
connected scenario ORs in the armed bit while CH5 is high, and the
warning message now derives from the flags byte with the firmware's
highest-bit-wins priority. Arming becomes testable mid-session by
flipping a switch mapped to CH5.
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