Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
*.luac
src/WIDGETS/ELRSVTXAdmin/presets.txt
src/SCRIPTS/ELRS/vtx/presets.txt
src/SCRIPTS/TOOLS/ExpressLRSBind/history.txt
bin/
.claude/plans/
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,20 @@ Widgets require a GUI screen. Both widgets running side-by-side on the home scre

The telemetry widget (`ExpressLRS Telemetry`) displays real-time link statistics on your home screen. Larger widget sizes show more information and long press to open a fullscreen view showing all the telemetry items in a list.

Black & white radios get the same telemetry as a telemetry screen: select the `ELRTLM` script on a page in Model Setup > Telemetry. The screen shows link quality, RSSI, RF mode, power and battery; press ENTER for the full list.

<img src="screenshots/telemetry_bw.png" width="256" alt="ELRS telemetry screen"><br/>

<img src="screenshots/widget_telemetry_fullscren.png" width="480" alt="ELRS Telemetry Widget">

## VTX Administrator Widget

The VTX Administrator widget (`ExpressLRS VTX Admin`) is a shortcut for editing the VTX Admin settings normally accessed from the ExpressLRS tool script. It supports binding VTX channels to a 6POS for switching channels without digging into menus, with multiple profiles. Long press the widget to open the fullscreen configuration UI.

Black & white radios get the same features as a telemetry screen: select the `ELRVTX` script on a page in Model Setup > Telemetry. The screen shows the current band, channel, power, pit mode and 6POS presets; press ENTER for settings.

<img src="screenshots/telemetry_vtxadmin_bw.png" width="256" alt="VTX Administrator telemetry screen"><br/>

<img src="screenshots/widget_vtxadmin_fullscreen.png" width="480" alt="VTX Administrator Widget">

## Compatibility
Expand Down
38 changes: 28 additions & 10 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,24 +60,34 @@ The telemetry widget, `WIDGETS/ELRSTelemetry/`:
| Module | Purpose |
|--------|---------|
| `main.lua` | Entry point and the EdgeTX widget descriptor. Bootstraps the singletons into globals and resets the model-scoped state when the model changes |
| `telemetry.lua` | Everything the widget knows about the link, in one owner: the frame drain, the DEVICE_INFO cache, the model-match status and the rules for when it may be asked for, the per-tick sensor snapshot, and every value derived from it. The only file in the widget that sees `crsf` |
| `rf_modes.lua` | Packet-rate names and rated RSSI floors, keyed by the module's firmware major version. Pure data plus its selector, apart from the policy because it versions on ExpressLRS's release clock |
| `loadable.lua` | One per placed widget: picks the layout for the screen, wires the components, drives them from the widget callbacks. Owns no state |
| `ui/display.lua` | The read model: zero-argument formatters the layouts pass to LVGL as `text`/`color` callbacks, plus `WidgetLayout`. The whole vocabulary the view has |
| `ui/fullscreen.lua` | The full-screen page, one layout for every screen size. Loaded on first entry |
| `ui/<screen>.lua` | Minimized layout per screen size, each with its own breakpoints, fonts and height tiers |
| `ui/topbar.lua` | Top-bar layout, shared by every screen file |

`WIDGETS/ELRSVTXAdmin/` has the same shape, with `loadable.lua` holding the domain (it is per
instance, being a config client rather than shared link state) and `presets_storage.lua` alongside it.
Its domain lives in the library so the B&W telemetry screen can share it: `SCRIPTS/ELRS/telemetry/state.lua`
is the link state and `telemetry/rf_modes.lua` its rate tables. The B&W counterpart is the telemetry
script `SCRIPTS/TELEMETRY/ELRTLM.lua`: it drains and samples from `background()`, `run()` draws
`telemetry/lcd/dashboard.lua`, and ENTER loads the `telemetry/lcd/details.lua` list, released on exit.

`WIDGETS/ELRSVTXAdmin/` has the same shape. Its domain lives in the library so the B&W telemetry
screen can share it: `SCRIPTS/ELRS/vtx/admin.lua` is loaded once per instance (a config client, not
shared link state), and `SCRIPTS/ELRS/vtx/presets.lua` holds the 6POS presets, shared by every instance.

The B&W counterpart is the telemetry script `SCRIPTS/TELEMETRY/ELRVTX.lua`. It ticks the core from
`background()`, which the firmware runs whether or not the screen is visible, so 6POS automation keeps
working on other screens. `run()` draws `SCRIPTS/ELRS/vtx/lcd/dashboard.lua`; ENTER loads
`vtx/lcd/menu.lua`, which is released again on exit because a telemetry script stays resident for
the whole session.

Two rules hold across both widgets. The per-screen `ui/<screen>.lua` files are deliberately
self-contained -- own breakpoints, own fonts, own tier builders -- so changing one screen cannot break
another; shared *presentation* goes in `ui/display.lua` instead. And no `ui/` file receives the CRSF
transport: the view asks the read model, which asks the domain.

The telemetry widget's state is shared by every instance of it, because it describes the radio's link
rather than a widget. That costs three things, all stated in `telemetry.lua`'s header: `drain()` runs
rather than a widget. That costs three things, all stated in `state.lua`'s header: `drain()` runs
per instance and ungated (each instance owns a firmware pop queue only it can empty), `update()`
samples at most once per tick (or the range smoother steps once per instance per frame), and the frame
handlers stay pure assignment (every instance is delivered its own copy of each frame, so the
Expand All @@ -88,25 +98,33 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too
| Module | Purpose |
|--------|---------|
| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) |
| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget |
| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine (one step in flight, retried when the radio's single output slot refuses it), and optional device discovery, link status and unsupported-firmware detection (ELRS 1.x, or a TX below 3.5.4). Loaded by the tool and the VTX Admin widget |
| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and VTX Admin |
| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine (one step in flight, retried when the radio's single output slot refuses it), and optional device discovery, link status and unsupported-firmware detection (ELRS 1.x, or a TX below 3.5.4). Loaded by the tool and VTX Admin |
| `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool |
| `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool |
| `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui/<display>/` is the library's home for shared UI components, mirroring the tools' own `ui/` split |
| `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui/<display>/` is the library's home for shared UI components (single-consumer feature UI lives with its feature, e.g. `vtx/lcd/`), mirroring the tools' own `ui/` split |
| `SCRIPTS/ELRS/ui/lcd/dialogs.lua` | BW full-screen dialogs (MIDSIZE title, body lines, optional bottom action labels), redrawn every frame: `draw` plus the version gate and the missing-module notice. Loaded by both tools' BW UIs |
| `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | Color-LCD dialogs: `showConfirm`/`showMessage` over the firmware popups, plus the version gate and the missing-module notice a tool raises before it has a page. Those two are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs; the widgets' full-screen pages reuse `noModuleChecklist` |
| `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` |
| `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` and the `REQUIRED_VERSIONS` dialog lines (`deps.requiredVersions`) to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step |
| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue`. A cached ID addresses a slot in the model that was loaded when it was resolved, so a consumer that survives a model change must call `crsf.resetSensorCache()` on that edge |
| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool |
| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by VTX Admin and the bind tool |
| `SCRIPTS/ELRS/telemetry/state.lua` | Everything known about the link, in one owner: the frame drain, the DEVICE_INFO cache, the model-match status and the rules for when it may be asked for, the per-tick sensor snapshot, and every value derived from it. A singleton shared by every telemetry widget instance; loaded once by `ELRTLM` |
| `SCRIPTS/ELRS/telemetry/rf_modes.lua` | Packet-rate names and rated RSSI floors, keyed by the module's firmware major version. Pure data plus its selector, apart from the policy because it versions on ExpressLRS's release clock |
| `SCRIPTS/ELRS/telemetry/lcd/dashboard.lua`, `details.lua` | The telemetry screen's dashboard (LQ, bars, signal, power, battery) and its scrolling list of every reading |
| `SCRIPTS/ELRS/vtx/admin.lua` | VTX Admin domain: discovers the module's VTX Admin folder, parses its name into state, stages writes (`writeConfig`) and sends them (`pushToVtx`), and runs the 6POS and push-trigger automation. One per widget instance or telemetry script |
| `SCRIPTS/ELRS/vtx/presets.lua` | 6POS preset collections over `file_storage.lua`, persisted to `/SCRIPTS/ELRS/vtx/presets.txt` |
| `SCRIPTS/ELRS/vtx/lcd/dashboard.lua`, `menu.lua` | The VTX Admin telemetry screen's dashboard and its menu (VTX settings, 6POS Quick Change, Presets). Sources are picked by rotary or by moving the control |
| `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios |

Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and
the firmware delivers every widget instance its own copy of each incoming frame, so **each script
instance has exactly one draining consumer**: the config tool and the VTX Admin widget drain through
`session:drain()`, the telemetry widget through `Telemetry.drain()`, and the bind tool through
`crsf.drain(App, App.onFrame)`. A future widget needing two consumers must pop once and route the
frames itself. `reassemble()` callers pass the field id they
frames itself. B&W firmware has a single queue for every script instead, so there `crsf.lua` pops
into a short shared log and hands each consumer its own copy through a per-consumer cursor; the
telemetry scripts share one `crsf` through the `_crsfSingleton` global for that. `reassemble()` callers pass the field id they
are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`,
used by VTX Admin so sibling instances stay in sync from each other's answers).

Expand Down
17 changes: 15 additions & 2 deletions edgetx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ package:
- screenshots/tool_bind.png
- screenshots/tool_bind_bw.png
- screenshots/widget_telemetry_fullscren.png
- screenshots/telemetry_bw.png
- screenshots/widget_vtxadmin_fullscreen.png
- screenshots/telemetry_vtxadmin_bw.png
- screenshots/widgets.png
keywords:
- expresslrs
Expand All @@ -29,6 +31,8 @@ package:
libraries:
- name: ELRS
path: SCRIPTS/ELRS
exclude:
- presets.txt

- name: CRSFSimulator
path: SCRIPTS/CRSFSimulator
Expand Down Expand Up @@ -57,5 +61,14 @@ widgets:
path: WIDGETS/ELRSVTXAdmin
depends:
- ELRS
exclude:
- presets.txt

telemetry:
- name: ELRTLM
path: SCRIPTS/TELEMETRY/ELRTLM.lua
depends:
- ELRS

- name: ELRVTX
path: SCRIPTS/TELEMETRY/ELRVTX.lua
depends:
- ELRS
Binary file added screenshots/telemetry_bw.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added screenshots/telemetry_vtxadmin_bw.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions src/SCRIPTS/CRSFSimulator/csrfsimulator.lua
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,9 @@ local pwmChannelConfig = {
[4] = { inputChannel = 4, mode = 0, inverted = 0 },
}

-- Firmware gives each Lua state its own frame queue: a shared log with
-- per-consumer cursors, new consumers start at the tail.
-- Colour firmware gives each widget its own frame queue: a shared log with
-- per-consumer cursors, new consumers start at the tail. On B&W crsf.lua
-- pops with one key, as there the firmware has a single queue.

local frameLog = {}
local logTotal = 0
Expand Down
57 changes: 54 additions & 3 deletions src/SCRIPTS/ELRS/crsf.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
-- --
-- Protocol constants, pop/push (with the simulator mock seam) and --
-- link-layer decoders. Each script instance has its own frame queue on --
-- colour radios; consumers drain theirs with CRSF.drain(). --
-- colour radios; B&W radios have one, shared through a log here. --
-- Consumers drain with CRSF.drain(). --
---------------------------------------------------------------------------

local shim = loadScript("/SCRIPTS/ELRS/shim.lua")()
Expand Down Expand Up @@ -87,11 +88,61 @@ CRSF.hasTelemetry = false
-- Transport (setMock() swaps these in the simulator)
-- ============================================================================

--- Pop one frame. consumer is only used by the simulator mock.
-- B&W firmware has one queue for every script: consumers read a log of it
-- through their own cursor, new ones from the tail
local SHARED_QUEUE = lvgl == nil
local LOG_MAX = 8 -- frames kept for a consumer that stopped draining
local frameLog = {}
local logHead = 0
local logTail = 1
local cursors = setmetatable({}, { __mode = "k" })

local function pruneLog()
local oldest = logHead + 1
for _, nextIdx in pairs(cursors) do
if nextIdx < oldest then
oldest = nextIdx
end
end
oldest = math.max(oldest, logHead - LOG_MAX + 1)
for i = logTail, oldest - 1 do
frameLog[i] = nil
end
logTail = math.max(logTail, oldest)
end

local function sharedPop(consumer)
local nextIdx = math.max(cursors[consumer] or (logHead + 1), logTail)
if nextIdx > logHead then
local command, data = CRSF._popImpl(frameLog)
if command == nil then
cursors[consumer] = nextIdx
return nil
end
logHead = logHead + 1
frameLog[logHead] = { command, data }
end
cursors[consumer] = nextIdx + 1
local frame = frameLog[nextIdx]
pruneLog()
-- Copy: decoders convert strings in place
local data = {}
for i = 1, #frame[2] do
data[i] = frame[2][i]
end
return frame[1], data
end

--- Pop one frame for consumer.
-- Link state refreshes only on an empty pop, so frames land before the flip.
-- The TX zeroes RQly on disconnect.
function CRSF.pop(consumer)
local command, data = CRSF._popImpl(consumer)
local command, data
if SHARED_QUEUE then
command, data = sharedPop(consumer)
else
command, data = CRSF._popImpl(consumer)
end
if command == nil then
CRSF.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0
end
Expand Down
9 changes: 9 additions & 0 deletions src/SCRIPTS/ELRS/crsf_session.lua
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ function CRSFSession:reloadAll()
params.resetChunks(self.rx)
end

--- Drop all stored fields except the given ids; frees discovery leftovers.
function CRSFSession:retainFields(ids)
local kept = {}
for _, id in ipairs(ids) do
kept[id] = self._fields[id]
end
self._fields = kept
end

function CRSFSession:reloadField(field)
self._nextReadAt = 0
params.resetChunks(self.rx)
Expand Down
4 changes: 2 additions & 2 deletions src/SCRIPTS/ELRS/loader.lua
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@

local function loader(path, ...)
collectgarbage("collect")
local chunk = loadScript(path)
local chunk, err = loadScript(path)
if chunk == nil then
error(path) -- names the file on the error screen
error(err or path) -- file and reason on the error screen
end
return chunk(...)
end
Expand Down
Loading