Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ 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
Expand Down
16 changes: 12 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,17 @@ 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 |

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.
Expand All @@ -84,7 +87,7 @@ another; shared *presentation* goes in `ui/display.lua` instead. And no `ui/` fi
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 @@ -106,6 +109,9 @@ The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too
| `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 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 |
Expand All @@ -116,7 +122,9 @@ the firmware delivers every widget instance its own copy of each incoming frame,
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
6 changes: 6 additions & 0 deletions edgetx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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
Expand Down Expand Up @@ -62,6 +63,11 @@ widgets:
- ELRS

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

- name: ELRVTX
path: SCRIPTS/TELEMETRY/ELRVTX.lua
depends:
Expand Down
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.
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
123 changes: 123 additions & 0 deletions src/SCRIPTS/ELRS/telemetry/lcd/dashboard.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---------------------------------------------------------------------------
-- B&W Telemetry Dashboard --
---------------------------------------------------------------------------

local Telemetry = ...

local Dashboard = {}

local Dialogs

local TITLE_H = 9
local BAR_H = 5
local HERO_Y = 11
local LQ_BAR_Y = 27
local RSSI_Y = 34
local RSSI_BAR_Y = 41
local GRID_Y = 48
local GRID_LINE = 8
local MID = math.floor(LCD_W / 2)

local function dash(v, text)
if v == nil or not Telemetry.isConnected() then
return "--"
end
return text
end

function Dashboard.drawTitle()
lcd.drawFilledRectangle(0, 0, LCD_W, TITLE_H)
lcd.drawText(1, 1, "ExpressLRS", INVERS)
local level = Telemetry.statusLevel()
local STATUS = Telemetry.STATUS
if level == STATUS.NO_TELEMETRY then
lcd.drawText(LCD_W - 1, 2, "No telemetry", SMLSIZE + INVERS + RIGHT)
elseif level == STATUS.MISMATCH then
lcd.drawText(LCD_W - 1, 2, "MISMATCH!", SMLSIZE + INVERS + RIGHT + BLINK)
elseif level == STATUS.OK then
lcd.drawText(LCD_W - 1, 2, Telemetry.rfModeName() or "", SMLSIZE + INVERS + RIGHT)
end
end

local function drawBar(y, pct)
lcd.drawRectangle(0, y, LCD_W, BAR_H)
if pct then
local w = math.floor((LCD_W - 2) * math.max(0, math.min(100, pct)) / 100)
if w > 0 then
lcd.drawFilledRectangle(1, y + 1, w, BAR_H - 2)
end
end
end

local function drawHero()
local link = Telemetry.link
local connected = Telemetry.isConnected()
local lq = connected and tostring(link.rqly or 0) or "--"
lcd.drawText(1, HERO_Y, lq, DBLSIZE)
lcd.drawText(36, HERO_Y + 8, "LQ %", SMLSIZE)
if not connected or not Telemetry.hasDiversity() then
return
end
local ant = Telemetry.activeAnt()
if Telemetry.isXband() then
lcd.drawText(LCD_W - 1, HERO_Y + 8, ant == 1 and "SubG" or "2.4", SMLSIZE + RIGHT)
else
lcd.drawText(LCD_W - 1, HERO_Y + 8, "Ant " .. ant, SMLSIZE + RIGHT)
end
end

local function signalText()
local link = Telemetry.link
local rssi = Telemetry.activeRssi()
if rssi == nil or not Telemetry.isConnected() then
return "--"
end
local text = tostring(rssi)
if Telemetry.hasDiversity() then
text = (link.rssi1 or "--") .. " " .. link.rssi2
end
if link.sens then
text = text .. " / " .. link.sens
end
return text .. "dBm"
end

local function cellText()
local vbat = Telemetry.link.vbat
local cells = Telemetry.cellCnt
if vbat == nil or vbat <= 0 or cells == nil then
return "--"
end
return string.format("%dS %.2fV", cells, vbat / cells)
end

local function drawCell(x, right, y, caption, text)
lcd.drawText(x, y, caption, SMLSIZE)
lcd.drawText(right, y, text, SMLSIZE + RIGHT)
end

local function drawGrid()
local link = Telemetry.link
local y2 = GRID_Y + GRID_LINE
drawCell(1, MID - 3, GRID_Y, "PWR", dash(link.tpwr, (link.tpwr or "") .. "mW"))
drawCell(1, MID - 3, y2, "BATT", cellText())
drawCell(MID + 2, LCD_W - 1, GRID_Y, "TQly", dash(link.tqly, (link.tqly or "") .. "%"))
drawCell(MID + 2, LCD_W - 1, y2, "TRSS", dash(link.trss, (link.trss or "") .. "dBm"))
end

function Dashboard.draw()
if not Telemetry.hasModule() then
Dialogs = Dialogs or loadScript("/SCRIPTS/ELRS/ui/lcd/dialogs.lua")()
Dialogs.drawNoModule()
return
end
lcd.clear()
Dashboard.drawTitle()
drawHero()
drawBar(LQ_BAR_Y, Telemetry.isConnected() and Telemetry.link.rqly)
drawCell(1, LCD_W - 1, RSSI_Y, "RSSI", signalText())
drawBar(RSSI_BAR_Y, Telemetry.headroomPct)
drawGrid()
end

return Dashboard
Loading