New LUA scripts & widgets - #3
Open
jurgelenas wants to merge 166 commits into
Open
Conversation
…ns for mk3 and 1 column for small screen radios
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.