diff --git a/.gitignore b/.gitignore index 531c909..e7c4b5d 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index b95814b..9412ebc 100644 --- a/README.md +++ b/README.md @@ -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. + +ELRS telemetry screen
+ 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. + +VTX Administrator telemetry screen
+ VTX Administrator Widget ## Compatibility diff --git a/docs/development.md b/docs/development.md index eb0b0f2..294ceb0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -60,16 +60,26 @@ 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/.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/.lua` files are deliberately self-contained -- own breakpoints, own fonts, own tier builders -- so changing one screen cannot break @@ -77,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 @@ -88,17 +98,23 @@ 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//` 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//` 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 @@ -106,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). diff --git a/edgetx.yml b/edgetx.yml index 0420184..7df26dd 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -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 @@ -29,6 +31,8 @@ package: libraries: - name: ELRS path: SCRIPTS/ELRS + exclude: + - presets.txt - name: CRSFSimulator path: SCRIPTS/CRSFSimulator @@ -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 diff --git a/screenshots/telemetry_bw.png b/screenshots/telemetry_bw.png new file mode 100644 index 0000000..65e3058 Binary files /dev/null and b/screenshots/telemetry_bw.png differ diff --git a/screenshots/telemetry_vtxadmin_bw.png b/screenshots/telemetry_vtxadmin_bw.png new file mode 100644 index 0000000..e5d2820 Binary files /dev/null and b/screenshots/telemetry_vtxadmin_bw.png differ diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua index f192d0e..aa0d011 100644 --- a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -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 diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua index a1401f8..703e918 100644 --- a/src/SCRIPTS/ELRS/crsf.lua +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -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")() @@ -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 diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua index a74f938..9f5ce9e 100644 --- a/src/SCRIPTS/ELRS/crsf_session.lua +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -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) diff --git a/src/SCRIPTS/ELRS/loader.lua b/src/SCRIPTS/ELRS/loader.lua index 2e534e8..80eaa17 100644 --- a/src/SCRIPTS/ELRS/loader.lua +++ b/src/SCRIPTS/ELRS/loader.lua @@ -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 diff --git a/src/SCRIPTS/ELRS/telemetry/lcd/dashboard.lua b/src/SCRIPTS/ELRS/telemetry/lcd/dashboard.lua new file mode 100644 index 0000000..42fc08e --- /dev/null +++ b/src/SCRIPTS/ELRS/telemetry/lcd/dashboard.lua @@ -0,0 +1,125 @@ +--------------------------------------------------------------------------- +-- B&W Telemetry Dashboard -- +-- Loaded via loadScript() with (Telemetry). -- +--------------------------------------------------------------------------- + +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 + +--- e.g. "-85 -92 / -112dBm" +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 diff --git a/src/SCRIPTS/ELRS/telemetry/lcd/details.lua b/src/SCRIPTS/ELRS/telemetry/lcd/details.lua new file mode 100644 index 0000000..10bd5dd --- /dev/null +++ b/src/SCRIPTS/ELRS/telemetry/lcd/details.lua @@ -0,0 +1,174 @@ +--------------------------------------------------------------------------- +-- B&W Telemetry Details -- +-- Loaded via loadScript() with (Telemetry, Dashboard) on ENTER, dropped -- +-- on exit. run(event) returns true when closed. -- +--------------------------------------------------------------------------- + +local Telemetry, Dashboard = ... + +local ROW_H = 8 +local ROW_Y = 11 +local ROW_COUNT = math.floor((LCD_H - ROW_Y) / ROW_H) + +local link = Telemetry.link + +local function connected(v) + return v ~= nil and Telemetry.isConnected() +end + +local function rfMode() + return Telemetry.isConnected() and Telemetry.rfModeName() or nil +end + +local function lq() + return Telemetry.isConnected() and (link.rqly or 0) .. "%" or nil +end + +local function rssi() + if not connected(link.rssi1) then + return nil + end + if Telemetry.hasDiversity() then + return link.rssi1 .. " / " .. link.rssi2 .. "dBm" + end + return link.rssi1 .. "dBm" +end + +local function snr() + if not connected(link.rsnr) then + return nil + end + if not Telemetry.hasSnr() then + return "n/a" -- FLRC: the sensor is a permanent 0, not a reading + end + return link.rsnr .. "dB" +end + +local function antLabel() + return Telemetry.isXband() and "Active Band" or "Active Antenna" +end + +local function ant() + if not Telemetry.isConnected() then + return nil + end + if not Telemetry.hasDiversity() then + return "N/A" + end + if Telemetry.isXband() then + return Telemetry.activeAnt() == 1 and "SubG" or "2.4" + end + return "Ant " .. Telemetry.activeAnt() +end + +local function sens() + return connected(link.sens) and link.sens .. "dBm" or nil +end + +local function margin() + local db = Telemetry.marginDb() + return connected(db) and string.format("%+ddB", db) or nil +end + +local function power() + return connected(link.tpwr) and link.tpwr .. "mW" or nil +end + +local function pack() + local vbat = link.vbat + return vbat and vbat > 0 and string.format("%.2fV", vbat) or nil +end + +local function cells() + local vbat, n = link.vbat, Telemetry.cellCnt + return vbat and vbat > 0 and n and string.format("%dS %.2fV", n, vbat / n) or nil +end + +local function current() + local curr = link.curr + return curr and curr > 0 and string.format("%.2fA", curr) or nil +end + +local function flightMode() + local fm = link.fm + return fm ~= nil and fm ~= 0 and tostring(fm) or nil +end + +local function sats() + return connected(link.sats) and tostring(link.sats) or nil +end + +local function speed() + return connected(link.gspd) and string.format("%.1f", link.gspd) or nil +end + +local function alt() + return connected(link.alt) and tostring(link.alt) or nil +end + +-- Last known position survives link loss +local function lat() + return Telemetry.gps and tostring(Telemetry.gps.lat) +end + +local function lon() + return Telemetry.gps and tostring(Telemetry.gps.lon) +end + +-- A row without a value is a section header +local rows = { + { "Link Status" }, + { "RF Mode", rfMode }, + { "Link Quality", lq }, + { "RSSI", rssi }, + { "SNR", snr }, + { antLabel, ant }, + { "Sensitivity", sens }, + { "Link Margin", margin }, + { "Power" }, + { "TX Power", power }, + { "Flight Controller" }, + { "Battery", pack }, + { "Cells", cells }, + { "Current", current }, + { "Flight Mode", flightMode }, + { "GPS" }, + { "Satellites", sats }, + { "Speed", speed }, + { "Altitude", alt }, + { "Latitude", lat }, + { "Longitude", lon }, +} + +local MAX_OFFSET = math.max(0, #rows - ROW_COUNT) + +local offset = 0 + +local function run(event) + if event == EVT_VIRTUAL_EXIT then + return true + elseif event == EVT_VIRTUAL_NEXT then + offset = math.min(offset + 1, MAX_OFFSET) + elseif event == EVT_VIRTUAL_PREV then + offset = math.max(offset - 1, 0) + end + + lcd.clear() + Dashboard.drawTitle() + for i = 1, math.min(ROW_COUNT, #rows - offset) do + local row = rows[offset + i] + local y = ROW_Y + (i - 1) * ROW_H + local label = row[1] + if type(label) == "function" then + label = label() + end + if row[2] then + lcd.drawText(1, y, label) + lcd.drawText(LCD_W - 1, y, row[2]() or "--", RIGHT) + else + lcd.drawText(1, y, label, BOLD) + end + end +end + +return { run = run } diff --git a/src/WIDGETS/ELRSTelemetry/rf_modes.lua b/src/SCRIPTS/ELRS/telemetry/rf_modes.lua similarity index 94% rename from src/WIDGETS/ELRSTelemetry/rf_modes.lua rename to src/SCRIPTS/ELRS/telemetry/rf_modes.lua index 10852f2..5a93f6b 100644 --- a/src/WIDGETS/ELRSTelemetry/rf_modes.lua +++ b/src/SCRIPTS/ELRS/telemetry/rf_modes.lua @@ -1,7 +1,7 @@ --------------------------------------------------------------------------- -- ELRS RF Mode Tables -- --- Loaded via loadScript() from ELRSTelemetry/telemetry.lua with no -- --- arguments; returns the RfModes table. -- +-- Loaded via loadScript() from telemetry/state.lua with no arguments; -- +-- returns the RfModes table. -- -- -- -- Packet-rate names and sensitivity floors per ELRS major version. -- -- RFMD is 0-based; callers pass the raw sensor value. -- @@ -161,7 +161,7 @@ end function RfModes.name(rfmd) local names = RfModes._names - return (names and names[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) + return (names and names[rfmd + 1]) or ("RFMD" .. rfmd) end function RfModes.floor(rfmd) diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/SCRIPTS/ELRS/telemetry/state.lua similarity index 98% rename from src/WIDGETS/ELRSTelemetry/telemetry.lua rename to src/SCRIPTS/ELRS/telemetry/state.lua index d84fc70..7c626a9 100644 --- a/src/WIDGETS/ELRSTelemetry/telemetry.lua +++ b/src/SCRIPTS/ELRS/telemetry/state.lua @@ -1,6 +1,6 @@ --------------------------------------------------------------------------- -- ELRS Telemetry State -- --- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- +-- Loaded via loadScript("/SCRIPTS/ELRS/telemetry/state.lua") (crsf); -- -- returns the Telemetry singleton. -- -- -- -- Shared by all widget instances. Each instance gets its own copy of -- @@ -9,7 +9,7 @@ local crsf = ... -local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() +local RfModes = loadScript("/SCRIPTS/ELRS/telemetry/rf_modes.lua")() local Telemetry = {} diff --git a/src/SCRIPTS/ELRS/vtx/admin.lua b/src/SCRIPTS/ELRS/vtx/admin.lua new file mode 100644 index 0000000..bd6786e --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/admin.lua @@ -0,0 +1,464 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Core -- +-- Client of the module's VTX Admin folder, plus 6POS automation. -- +-- Loaded via loadScript("/SCRIPTS/ELRS/vtx/admin.lua") -- +-- (crsf, CRSFSession, PresetsStorage); one instance per consumer. -- +--------------------------------------------------------------------------- + +local crsf, CRSFSession, PresetsStorage = ... + +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + +-- Passive: also sees fields sibling instances requested +local session + +local VTXAdmin = { + PHASE_INIT = 0, + PHASE_NO_MODULE = 1, + PHASE_DISCOVER_ROOT = 2, + PHASE_DISCOVER_CHILDREN = 3, + PHASE_DISCOVER_VTX = 4, + PHASE_READY = 5, + PHASE_SENDING = 6, + + phase = 0, -- PHASE_INIT + + -- Detects suspension by a standalone tool + lastTick = 0, + + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- Discovered by name at runtime + ids = { + folder = nil, + band = nil, + channel = nil, + power = nil, + pitmode = nil, + send = nil, + }, + + -- Band 0 has no letter: "Off" for the VTX, "--" for an unused preset + BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, + BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, + + statusText = "Initializing...", + + -- Mutated in place; UI closures hold references + state = { + band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L + bandLetter = "?", + channel = 0, + power = 0, + pitmode = false, -- true only when pit mode is confirmed on + pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch + }, + + -- Edited in full screen; mutated in place + desired = { + band = 5, -- Raceband + channel = 1, + power = 0, + pitmode = 0, + }, +} + +--- Parse "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])" into state. +--- PITMODE is "P", or an aux label plus up/down arrow when switch-bound. +--- Band Off drops the suffix, power "-" drops power and pit mode. +--- Aux binding sets pitmodeAux only: the name never has the switch position. +local function parseFolderName(name) + local s = VTXAdmin.state + local content = string.match(name, "%((.+)%)") + if not content then + s.band = 0 + s.bandLetter = "Off" + s.channel = 0 + s.power = 0 + s.pitmode = false + s.pitmodeAux = nil + return true + end + + local parts = {} + for part in string.gmatch(content, "([^:]+)") do + parts[#parts + 1] = part + end + if #parts < 2 then + return false + end + + s.bandLetter = parts[1] + s.band = VTXAdmin.BAND_VALUES[parts[1]] or 0 + s.channel = tonumber(parts[2]) or 0 + s.power = tonumber(parts[3]) or 0 + if #parts < 4 then + s.pitmode = false + s.pitmodeAux = nil + elseif parts[4] == "P" then + s.pitmode = true + s.pitmodeAux = nil + else + -- Strip the trailing arrow (multi-byte CHAR_UP/CHAR_DOWN) + s.pitmode = false + local part = parts[4] + if CHAR_UP and string.sub(part, -#CHAR_UP) == CHAR_UP then + s.pitmodeAux = string.sub(part, 1, -#CHAR_UP - 1) + elseif CHAR_DOWN and string.sub(part, -#CHAR_DOWN) == CHAR_DOWN then + s.pitmodeAux = string.sub(part, 1, -#CHAR_DOWN - 1) + else + s.pitmodeAux = string.sub(part, 1, -2) + end + end + return true +end + +function VTXAdmin.isTuned() + return VTXAdmin.isActive() and VTXAdmin.state.band > 0 +end + +function VTXAdmin.isDisabled() + return VTXAdmin.isActive() and VTXAdmin.state.band == 0 +end + +--- Power "-" drops power and pit mode from the folder name. +function VTXAdmin.hasPower() + return VTXAdmin.isTuned() and VTXAdmin.state.power > 0 +end + +function VTXAdmin.syncDesiredFromState() + local s = VTXAdmin.state + local d = VTXAdmin.desired + d.band = s.band + d.channel = s.channel + d.power = s.power + d.pitmode = s.pitmode and 1 or 0 +end + +-- ============================================================================ +-- VTXAdmin: state machine query helpers +-- ============================================================================ + +function VTXAdmin.isReady() + return VTXAdmin.phase == VTXAdmin.PHASE_READY +end + +function VTXAdmin.isSending() + return VTXAdmin.phase == VTXAdmin.PHASE_SENDING +end + +function VTXAdmin.isActive() + return VTXAdmin.phase == VTXAdmin.PHASE_READY or VTXAdmin.phase == VTXAdmin.PHASE_SENDING +end + +--- Presets are local, so they show before discovery finishes. +function VTXAdmin.hasModule() + return VTXAdmin.phase ~= VTXAdmin.PHASE_NO_MODULE +end + +-- ============================================================================ +-- VTXAdmin: field handler (session onFieldUpdate callback) +-- ============================================================================ + +local function onField(field) + local fieldId = field.id + local fieldName = field.name + + if VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_ROOT then + if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then + -- Session auto-queues the root children + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_CHILDREN + VTXAdmin.statusText = "Discovering fields..." + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_CHILDREN then + if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then + VTXAdmin.ids.folder = fieldId + parseFolderName(fieldName) + session:loadFolder(fieldId) + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_VTX + VTXAdmin.statusText = "Loading VTX fields..." + elseif not session:isLoading() and VTXAdmin.ids.folder == nil then + VTXAdmin.statusText = "VTX Admin not found" + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_VTX then + if fieldName == "Band" or fieldName == "Band/Enable" then + VTXAdmin.ids.band = fieldId + elseif fieldName == "Channel" then + VTXAdmin.ids.channel = fieldId + elseif fieldName == "Pwr Lvl" then + VTXAdmin.ids.power = fieldId + elseif fieldName == "Pitmode" then + VTXAdmin.ids.pitmode = fieldId + elseif fieldName == "Send VTx" then + VTXAdmin.ids.send = fieldId + end + + if not session:isLoading() then + if + VTXAdmin.ids.band + and VTXAdmin.ids.channel + and VTXAdmin.ids.power + and VTXAdmin.ids.pitmode + and VTXAdmin.ids.send + then + VTXAdmin.phase = VTXAdmin.PHASE_READY + VTXAdmin.statusText = "" + VTXAdmin.syncDesiredFromState() + local ids = VTXAdmin.ids + session:retainFields({ ids.folder, ids.band, ids.channel, ids.power, ids.pitmode, ids.send }) + else + VTXAdmin.statusText = "VTX fields incomplete" + end + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_READY then + if fieldId == VTXAdmin.ids.folder then + parseFolderName(fieldName) + end + end +end + +session = CRSFSession.new({ + acceptUnsolicited = true, + responseTimeout = 50, -- always the local TX module + onFieldUpdate = onField, +}) + +-- ============================================================================ +-- VTXAdmin: 6POS quick-change and push-trigger automation +-- ============================================================================ + +local function mapTo6Pos(value) + local pos = math.floor((value + 1024) * 6 / 2049) + 1 + if pos < 1 then + pos = 1 + end + if pos > 6 then + pos = 6 + end + return pos +end + +--- Shared latch: one write per edge per radio, not per instance. +local function process6Pos() + if not PresetsStorage.enabled then + return + end + if PresetsStorage.source == 0 then + return + end + + local value = getValue(PresetsStorage.source) + if value == nil then + return + end + + local latch = PresetsStorage.latch + local pos = mapTo6Pos(value) + local now = getTime() + + -- Debounce; shared, as the source is radio state + if pos ~= latch.stablePos then + latch.stablePos = pos + latch.stableTime = now + return + end + if now - latch.stableTime < VTXAdmin.DEBOUNCE then + return + end + + -- First reading after boot or a source change: record it, don't retune, + -- unless Apply at Startup. The switch may boot on position 1. + if PresetsStorage.source ~= latch.sourceSeen then + latch.sourceSeen = PresetsStorage.source + latch.lastCollection = PresetsStorage.collection + if not PresetsStorage.applyOnStart then + latch.lastPos = pos + return + end + latch.lastPos = -1 + end + + -- Latch only once writable, or the edge is lost for every instance + if not VTXAdmin.isReady() then + return + end + + -- A collection change also retunes + if pos == latch.lastPos and PresetsStorage.collection == latch.lastCollection then + return + end + -- Push the startup apply: the link may already be up + local first = latch.lastPos == -1 + latch.lastPos = pos + latch.lastCollection = PresetsStorage.collection + + -- Unused slot: leave the VTX alone, no retry + local preset = PresetsStorage.items[pos] + if not preset or preset.band == 0 then + return + end + + VTXAdmin.applyPreset(preset.band, preset.channel) + if PresetsStorage.autoPushVtx or first then + VTXAdmin.pushToVtx() + end +end + +--- Rising edge on pushSource sends config; one push per radio. +local function processPushTrigger() + if PresetsStorage.autoPushVtx then + return + end + if PresetsStorage.pushSource == 0 then + return + end + + local val = getValue(PresetsStorage.pushSource) + if val == nil then + return + end + + local latch = PresetsStorage.latch + local high = val > 0 + + -- New source: adopt its level without firing (also seeds at boot) + if PresetsStorage.pushSource ~= latch.pushSourceSeen then + latch.pushSourceSeen = PresetsStorage.pushSource + latch.pushLastHigh = high + return + end + + -- Latch only once sendable, or the edge is lost + if not VTXAdmin.isReady() and not VTXAdmin.isSending() then + return + end + + local wasHigh = latch.pushLastHigh + latch.pushLastHigh = high + + if high and not wasHigh then + VTXAdmin.pushToVtx() + end +end + +-- ============================================================================ +-- VTXAdmin: state machine tick +-- ============================================================================ + +function VTXAdmin.tick() + session:drain() + local now = getTime() + + -- Resumed after >1 s: a tool may have changed config, so read back once. + -- Never poll: each read replaces an RC frame. + if VTXAdmin.lastTick > 0 and now - VTXAdmin.lastTick > 100 and VTXAdmin.phase == VTXAdmin.PHASE_READY then + session:refreshField(VTXAdmin.ids.folder, 0, 3) + end + VTXAdmin.lastTick = now + + if VTXAdmin.phase == VTXAdmin.PHASE_INIT then + if crsf.hasCrsfModule() then + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_ROOT + VTXAdmin.statusText = "Discovering..." + session:reloadAll() + else + VTXAdmin.phase = VTXAdmin.PHASE_NO_MODULE + VTXAdmin.statusText = "No CRSF module" + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_SENDING and not session:isWriting() then + print("VTXAdmin: write queue drained") + VTXAdmin.phase = VTXAdmin.PHASE_READY + -- ~100 ms for the module to apply the change + session:refreshField(VTXAdmin.ids.folder, 10, 3) + end + + session:tick() + + -- After the pump, so queued writes go out next tick + process6Pos() + processPushTrigger() +end + +-- ============================================================================ +-- VTXAdmin: write queue builder +-- ============================================================================ + +--- Write changed fields; pushToVtx() sends them to the VTX. +function VTXAdmin.writeConfig() + if not VTXAdmin.isReady() then + print("VTXAdmin: writeConfig() skipped - not ready") + return + end + + local s = VTXAdmin.state + local d = VTXAdmin.desired + + print(shim.tableConcat({ + "VTXAdmin: writeConfig() desired: band=", + d.band, + " ch=", + d.channel, + " pwr=", + d.power, + " pit=", + tostring(d.pitmode), + })) + print(shim.tableConcat({ + "VTXAdmin: writeConfig() current: band=", + s.band, + " ch=", + s.channel, + " pwr=", + s.power, + " pit=", + tostring(s.pitmode), + })) + + local wrote = 0 + if d.band ~= s.band then + session:writeField({ id = VTXAdmin.ids.band, value = d.band }) + wrote = wrote + 1 + end + if d.channel ~= s.channel then + session:writeField({ id = VTXAdmin.ids.channel, value = d.channel }) + wrote = wrote + 1 + end + if d.power ~= s.power then + session:writeField({ id = VTXAdmin.ids.power, value = d.power }) + wrote = wrote + 1 + end + + local desiredPit = d.pitmode + local currentPit = s.pitmode and 1 or 0 + if desiredPit ~= currentPit then + session:writeField({ id = VTXAdmin.ids.pitmode, value = desiredPit }) + wrote = wrote + 1 + end + + print(shim.tableConcat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) + + if wrote > 0 then + VTXAdmin.phase = VTXAdmin.PHASE_SENDING + end +end + +--- Re-sync first: desired may hold stale power/pitmode. +function VTXAdmin.applyPreset(band, channel) + VTXAdmin.syncDesiredFromState() + VTXAdmin.desired.band = band + VTXAdmin.desired.channel = channel + VTXAdmin.writeConfig() +end + +function VTXAdmin.pushToVtx() + if not VTXAdmin.isReady() and VTXAdmin.phase ~= VTXAdmin.PHASE_SENDING then + print("VTXAdmin: pushToVtx() skipped - not ready") + return + end + + print("VTXAdmin: pushToVtx() - sending Send VTx command") + session:writeField({ id = VTXAdmin.ids.send, value = crsf.CONST.CMD_CLICK }) + VTXAdmin.phase = VTXAdmin.PHASE_SENDING +end + +return VTXAdmin diff --git a/src/SCRIPTS/ELRS/vtx/lcd/dashboard.lua b/src/SCRIPTS/ELRS/vtx/lcd/dashboard.lua new file mode 100644 index 0000000..96495f1 --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/lcd/dashboard.lua @@ -0,0 +1,109 @@ +--------------------------------------------------------------------------- +-- B&W VTX Admin Dashboard -- +-- Loaded via loadScript() with (VTXAdmin, PresetsStorage, labels). -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage, labels = ... + +local Dashboard = {} + +local Dialogs + +local TITLE_H = 9 +local CELL_H = 11 +local CELL_GAP = 2 +local CELL_W = math.floor((LCD_W - 4 - 5 * CELL_GAP) / 6) +local CELLS_X = math.floor((LCD_W - (6 * CELL_W + 5 * CELL_GAP)) / 2) +local CELLS_Y = LCD_H - CELL_H - 2 +local HERO_H = 14 -- DBLSIZE cap height +local SIDE_X = LCD_W - 40 -- fits "Power 8" + +local function presetsShown() + return PresetsStorage.enabled +end + +--- The VTX's tuning, not the switch: they differ after boot. Switch breaks ties. +local function activePreset() + if not VTXAdmin.isTuned() then + return nil + end + local s = VTXAdmin.state + local items = PresetsStorage.items + local pos = PresetsStorage.latch.lastPos + if pos > 0 and items[pos].band == s.band and items[pos].channel == s.channel then + return pos + end + for i = 1, 6 do + if items[i].band == s.band and items[i].channel == s.channel then + return i + end + end +end + +local function drawTitle() + lcd.drawFilledRectangle(0, 0, LCD_W, TITLE_H) + lcd.drawText(1, 1, "VTX Admin", INVERS) + if VTXAdmin.isSending() then + lcd.drawText(LCD_W - 1, 1, "Sending", INVERS + RIGHT) + end +end + +local function drawCells() + local active = activePreset() + local items = PresetsStorage.items + for i = 1, 6 do + local x = CELLS_X + (i - 1) * (CELL_W + CELL_GAP) + local p = items[i] + local text = labels.preset(p.band, p.channel) + if i == active then + lcd.drawFilledRectangle(x, CELLS_Y, CELL_W, CELL_H) + lcd.drawText(x + math.floor(CELL_W / 2) + 1, CELLS_Y + 2, text, INVERS + CENTER) + else + lcd.drawRectangle(x, CELLS_Y, CELL_W, CELL_H) + lcd.drawText(x + math.floor(CELL_W / 2) + 1, CELLS_Y + 2, text, CENTER) + end + end +end + +local function drawHero(top, bottom) + local y = top + math.floor((bottom - top - HERO_H) / 2) + if not VTXAdmin.isActive() then + lcd.drawText(LCD_W / 2, y + 4, VTXAdmin.statusText, CENTER) + return + end + local s = VTXAdmin.state + if s.band == 0 then + lcd.drawText(LCD_W / 2, y, "VTX Off", DBLSIZE + CENTER) + return + end + lcd.drawText(4, y, labels.preset(s.band, s.channel), DBLSIZE) + if not VTXAdmin.hasPower() then + return + end + lcd.drawText(SIDE_X, y - 1, labels.power(s.power)) + if s.pitmode then + lcd.drawText(SIDE_X, y + 8, "PIT ON", INVERS) + elseif s.pitmodeAux then + lcd.drawText(SIDE_X, y + 8, "Pit " .. s.pitmodeAux) + else + lcd.drawText(SIDE_X, y + 8, "Pit Off") + end +end + +function Dashboard.draw() + if not VTXAdmin.hasModule() then + Dialogs = Dialogs or loadScript("/SCRIPTS/ELRS/ui/lcd/dialogs.lua")() + Dialogs.drawNoModule() + return + end + lcd.clear() + drawTitle() + if presetsShown() then + drawHero(TITLE_H, CELLS_Y) + drawCells() + else + drawHero(TITLE_H, LCD_H) + end +end + +return Dashboard diff --git a/src/SCRIPTS/ELRS/vtx/lcd/menu.lua b/src/SCRIPTS/ELRS/vtx/lcd/menu.lua new file mode 100644 index 0000000..e2408ff --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/lcd/menu.lua @@ -0,0 +1,305 @@ +--------------------------------------------------------------------------- +-- B&W VTX Admin Menu -- +-- Loaded via loadScript() with (VTXAdmin, PresetsStorage, labels) on -- +-- ENTER, dropped on exit. run(event) returns true when closed. -- +-- Rows are plain data, not closures: memory is tight here. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage, labels = ... + +local NUMBER = 1 +local TOGGLE = 2 +local SOURCE = 3 +local SEND = 4 +local FOLDER = 5 +local PRESET = 6 + +local ROW_H = 8 +local ROW_Y = 11 +local ROW_COUNT = math.floor((LCD_H - ROW_Y) / ROW_H) +local VALUE_X = LCD_W - 1 + +local MOVE_THRESHOLD = 300 -- under one step of a 6-switch group + +local d = VTXAdmin.desired +local PS = PresetsStorage + +local BAND_NAMES = { "Off", "A", "B", "E", "F", "R", "L" } +local POWER_NAMES = { "-", "1", "2", "3", "4", "5", "6", "7", "8" } + +local quickChangeRows = { + { kind = TOGGLE, label = "Enabled", key = "enabled" }, + { kind = SOURCE, label = "Source", key = "source", multiPos = true }, + { kind = TOGGLE, label = "Apply at Startup", key = "applyOnStart" }, + { kind = TOGGLE, label = "Auto Push to VTX", key = "autoPushVtx" }, + { kind = SOURCE, label = "Send VTx Trigger", key = "pushSource" }, +} + +local presetRows = { + { kind = NUMBER, label = "Collection", key = "collection", min = 1, max = PS.COLLECTION_COUNT }, +} +for i = 1, 6 do + presetRows[i + 1] = { kind = PRESET, label = "Preset " .. i, slot = i } +end + +local rootRows = { + { kind = NUMBER, label = "Band", key = "band", vtx = true, min = 0, max = 6, names = BAND_NAMES }, + { kind = NUMBER, label = "Channel", key = "channel", vtx = true, min = 1, max = 8 }, + { kind = NUMBER, label = "Power Level", key = "power", vtx = true, min = 0, max = 8, names = POWER_NAMES }, + { kind = TOGGLE, label = "Pit Mode", key = "pitmode", vtx = true }, + { kind = SEND }, + { kind = FOLDER, label = "6POS Quick Change", page = { title = "6POS Quick Change", rows = quickChangeRows } }, + { kind = FOLDER, label = "Presets", page = { title = "Presets", rows = presetRows } }, +} +local root = { title = "VTX Admin", rows = rootRows } + +local page = root +local cursor = 1 +local offset = 0 +local stack = {} + +local edit + +local srcIds, srcStart, srcGroup + +local function clamp(v, min, max) + return math.max(min, math.min(max, v)) +end + +local function read(row) + if row.vtx then + return d[row.key] + end + local v = PS[row.key] + if v == true then + return 1 + elseif v == false then + return 0 + end + return v +end + +local function write(row, v) + local key = row.key + if row.vtx then + d[key] = v + VTXAdmin.writeConfig() + elseif key == "collection" then + PS.selectCollection(v) + else + if row.kind == TOGGLE then + v = (v == 1) + end + PS[key] = v + PS.save() + end +end + +--- Sticks, pots and physical switches incl. groups; lvgl.source's filter. +local function buildSources() + srcIds, srcStart, srcGroup = { 0 }, {}, {} + for id in sources(MIXSRC_FIRST_INPUT, MIXSRC_MIN - 1) do + local name = (getFieldInfo(id) or {}).name or "" + if string.sub(name, 1, 5) ~= "input" and string.sub(name, 1, 3) ~= "lua" then + srcIds[#srcIds + 1] = id + end + end + -- Switches are the only upper-case Lua field names here; groups have none + for id in sources(MIXSRC_MAX + 1, MIXSRC_CH1 - 1) do + local info = getFieldInfo(id) + local head = info and string.sub(info.name, 1, 1) or "" + if not info or (head ~= "" and head == string.upper(head)) then + srcIds[#srcIds + 1] = id + srcGroup[#srcIds] = not info or nil + end + end + for i = 2, #srcIds do + srcStart[i] = getValue(srcIds[i]) or 0 + end +end + +--- Largest move since editing began; a switch turning on beats one turning +--- off, since a group switches both. preferGroup: a moved group wins. +local function movedSource(preferGroup) + local best, bestScore + for i = 2, #srcIds do + local delta = (getValue(srcIds[i]) or 0) - srcStart[i] + if math.abs(delta) > MOVE_THRESHOLD then + if preferGroup and srcGroup[i] then + return i + end + local score = math.abs(delta) * 2 + (delta > 0 and 1 or 0) + if not bestScore or score > bestScore then + best, bestScore = i, score + end + end + end + return best +end + +local function visibleRows() + local rows = {} + for _, row in ipairs(page.rows) do + -- ExpressLRS hides pit mode while power is "-" + if row.key ~= "pitmode" or d.power > 0 then + rows[#rows + 1] = row + end + end + return rows +end + +local function enter(row) + local kind = row.kind + if not edit then + if (row.vtx and not VTXAdmin.isActive()) or (kind == SEND and not VTXAdmin.isReady()) then + return + end + if kind == TOGGLE then + write(row, 1 - read(row)) + elseif kind == SEND then + VTXAdmin.writeConfig() + VTXAdmin.pushToVtx() + elseif kind == FOLDER then + stack[#stack + 1] = { page, cursor, offset } + page, cursor, offset = row.page, 1, 0 + elseif kind == PRESET then + local p = PS.items[row.slot] + edit = { value = p.band, channel = p.channel, part = 1 } + elseif kind == SOURCE then + buildSources() + edit = { value = 1 } + for i = 1, #srcIds do + if srcIds[i] == read(row) then + edit.value = i + end + end + else + edit = { value = read(row) } + end + return + end + if kind == PRESET then + if edit.part == 1 and edit.value > 0 then + edit.part = 2 + return + end + local p = PS.items[row.slot] + p.band, p.channel = edit.value, edit.channel + PS.save() + elseif kind == SOURCE then + write(row, srcIds[edit.value]) + else + write(row, edit.value) + end + edit = nil + srcIds, srcStart, srcGroup = nil, nil, nil +end + +local function drawValue(row, y, attr) + local kind = row.kind + local editing = edit and attr ~= 0 + local text + if row.vtx and not VTXAdmin.isActive() then + text = "--" + elseif kind == NUMBER then + local v = editing and edit.value or read(row) + text = row.names and row.names[v + 1] or tostring(v) + elseif kind == TOGGLE then + text = read(row) == 1 and "On" or "Off" + elseif kind == SOURCE then + local id = editing and srcIds[edit.value] or read(row) + if id ~= 0 then + lcd.drawSource(VALUE_X, y, id, attr + RIGHT) + return + end + text = "---" + else + local p = PS.items[row.slot] + local band, channel = p.band, p.channel + if editing then + band, channel = edit.value, edit.channel + end + if band > 0 and editing then + lcd.drawText(VALUE_X, y, tostring(channel), edit.part == 2 and attr + RIGHT or RIGHT) + lcd.drawText(VALUE_X - 8, y, VTXAdmin.BAND_LETTERS[band], edit.part == 1 and attr + RIGHT or RIGHT) + return + end + text = labels.preset(band, channel) + end + lcd.drawText(VALUE_X, y, text, attr + RIGHT) +end + +local function run(event) + local rows = visibleRows() + local row = rows[cursor] + if event == EVT_VIRTUAL_EXIT then + if edit then + edit = nil + srcIds, srcStart, srcGroup = nil, nil, nil + elseif #stack == 0 then + return true + else + local prev = stack[#stack] + stack[#stack] = nil + page, cursor, offset = prev[1], prev[2], prev[3] + end + elseif event == EVT_VIRTUAL_ENTER then + enter(row) + elseif event == EVT_VIRTUAL_NEXT or event == EVT_VIRTUAL_PREV then + local dir = event == EVT_VIRTUAL_NEXT and 1 or -1 + if not edit then + cursor = (cursor - 1 + dir) % #rows + 1 + elseif row.kind == SOURCE then + edit.value = clamp(edit.value + dir, 1, #srcIds) + elseif row.kind == PRESET and edit.part == 2 then + edit.channel = clamp(edit.channel + dir, 1, 8) + elseif row.kind == PRESET then + edit.value = clamp(edit.value + dir, 0, #VTXAdmin.BAND_LETTERS) + else + edit.value = clamp(edit.value + dir, row.min, row.max) + end + elseif edit and row.kind == SOURCE then + edit.value = movedSource(row.multiPos) or edit.value + end + + rows = visibleRows() + cursor = clamp(cursor, 1, #rows) + offset = clamp(offset, cursor - ROW_COUNT, cursor - 1) + + lcd.clear() + lcd.drawFilledRectangle(0, 0, LCD_W, 9) + lcd.drawText(1, 1, page.title, INVERS) + local status = "Off" + if VTXAdmin.isSending() then + status = "Sending" + elseif not VTXAdmin.isActive() then + status = "Loading" + elseif VTXAdmin.isTuned() then + status = labels.preset(VTXAdmin.state.band, VTXAdmin.state.channel) + end + lcd.drawText(VALUE_X, 1, status, INVERS + RIGHT) + + for i = 1, math.min(ROW_COUNT, #rows - offset) do + local r = rows[offset + i] + local y = ROW_Y + (i - 1) * ROW_H + local rowAttr, valueAttr = 0, 0 + if offset + i == cursor then + if edit then + valueAttr = INVERS + BLINK + else + lcd.drawFilledRectangle(0, y - 1, LCD_W, ROW_H) + rowAttr, valueAttr = INVERS, INVERS + end + end + if r.kind == SEND then + lcd.drawText(LCD_W / 2, y, VTXAdmin.isSending() and "[Sending...]" or "[Send VTx]", rowAttr + BOLD + CENTER) + elseif r.kind == FOLDER then + lcd.drawText(1, y, "> " .. r.label, rowAttr + BOLD) + else + lcd.drawText(1, y, r.label, rowAttr) + drawValue(r, y, valueAttr) + end + end +end + +return { run = run } diff --git a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua b/src/SCRIPTS/ELRS/vtx/presets.lua similarity index 89% rename from src/WIDGETS/ELRSVTXAdmin/presets_storage.lua rename to src/SCRIPTS/ELRS/vtx/presets.lua index 9249021..8dac5d6 100644 --- a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua +++ b/src/SCRIPTS/ELRS/vtx/presets.lua @@ -1,12 +1,14 @@ --------------------------------------------------------------------------- --- 6POS Preset Storage -- --- Loaded via loadScript() from ELRSVTXAdmin/main.lua with (FileStorage) -- --- and shared by every widget instance. -- +-- VTX Admin 6POS Preset Storage -- +-- Loaded via loadScript("/SCRIPTS/ELRS/vtx/presets.lua") -- +-- (FileStorage); shared by every VTX Admin instance. -- --------------------------------------------------------------------------- local FileStorage = ... -local PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt" +local PATH = "/SCRIPTS/ELRS/vtx/presets.txt" + +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -- One slot per 6POS position local COLLECTION_COUNT = 6 @@ -17,7 +19,7 @@ local SLOT_COUNT = 6 local SAVE_KEYS = { "enabled", "source", "applyOnStart", "autoPushVtx", "pushSource", "collection" } local COLLECTION_KEYS = {} for c = 1, COLLECTION_COUNT do - COLLECTION_KEYS[c] = table.concat({ "c", c }) + COLLECTION_KEYS[c] = shim.tableConcat({ "c", c }) SAVE_KEYS[#SAVE_KEYS + 1] = COLLECTION_KEYS[c] end @@ -124,9 +126,9 @@ function PresetsStorage.save() local slots = PresetsStorage.collections[c] local parts = {} for i = 1, SLOT_COUNT do - parts[i] = table.concat({ slots[i].band, ",", slots[i].channel }) + parts[i] = shim.tableConcat({ slots[i].band, ",", slots[i].channel }) end - values[COLLECTION_KEYS[c]] = table.concat(parts, ";") + values[COLLECTION_KEYS[c]] = shim.tableConcat(parts, ";") end FileStorage.write(PATH, SAVE_KEYS, values) end diff --git a/src/SCRIPTS/TELEMETRY/ELRTLM.lua b/src/SCRIPTS/TELEMETRY/ELRTLM.lua new file mode 100644 index 0000000..0d3cd73 --- /dev/null +++ b/src/SCRIPTS/TELEMETRY/ELRTLM.lua @@ -0,0 +1,40 @@ +--------------------------------------------------------------------------- +-- ExpressLRS Telemetry - B&W telemetry screen -- +-- Requires /SCRIPTS/ELRS on the SD card. -- +--------------------------------------------------------------------------- + +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() + +-- One per Lua state: B&W scripts share the frame queue through it +_crsfSingleton = _crsfSingleton or loader("/SCRIPTS/ELRS/crsf.lua") +local crsf = _crsfSingleton +crsf.resetSensorCache() -- outlives model changes +local Telemetry = loader("/SCRIPTS/ELRS/telemetry/state.lua", crsf) +local Dashboard = loader("/SCRIPTS/ELRS/telemetry/lcd/dashboard.lua", Telemetry) +local Details + +local function background() + Telemetry.drain() + Telemetry.update() +end + +local function run(event) + if Details then + if Details.run(event) then + Details = nil + collectgarbage("collect") + Dashboard.draw() + end + return 0 + end + if event == EVT_VIRTUAL_ENTER and Telemetry.hasModule() then + Details = loader("/SCRIPTS/ELRS/telemetry/lcd/details.lua", Telemetry, Dashboard) + Details.run(0) + return 0 + end + Dashboard.draw() + return 0 +end + +return { run = run, background = background } diff --git a/src/SCRIPTS/TELEMETRY/ELRVTX.lua b/src/SCRIPTS/TELEMETRY/ELRVTX.lua new file mode 100644 index 0000000..9bd4035 --- /dev/null +++ b/src/SCRIPTS/TELEMETRY/ELRVTX.lua @@ -0,0 +1,64 @@ +--------------------------------------------------------------------------- +-- ExpressLRS VTX Admin - B&W telemetry screen -- +-- Requires /SCRIPTS/ELRS on the SD card. -- +--------------------------------------------------------------------------- + +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() + +-- One per Lua state: B&W scripts share the frame queue through it +_crsfSingleton = _crsfSingleton or loader("/SCRIPTS/ELRS/crsf.lua") +local crsf = _crsfSingleton +crsf.resetSensorCache() -- outlives model changes +local params = loader("/SCRIPTS/ELRS/crsf_params.lua", crsf) +local CRSFSession = loader("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) +local FileStorage = loader("/SCRIPTS/ELRS/file_storage.lua") +local PresetsStorage = loader("/SCRIPTS/ELRS/vtx/presets.lua", FileStorage) +local VTXAdmin = loader("/SCRIPTS/ELRS/vtx/admin.lua", crsf, CRSFSession, PresetsStorage) + +local PRESET_LABELS = {} +for band = 1, #VTXAdmin.BAND_LETTERS do + for channel = 1, 8 do + PRESET_LABELS[band * 10 + channel] = VTXAdmin.BAND_LETTERS[band] .. channel + end +end + +local labels = {} + +function labels.preset(band, channel) + return PRESET_LABELS[band * 10 + channel] or "--" +end + +function labels.power(power) + return "Power " .. power +end + +local Dashboard = loader("/SCRIPTS/ELRS/vtx/lcd/dashboard.lua", VTXAdmin, PresetsStorage, labels) +local Menu + +local function background() + VTXAdmin.tick() +end + +local function run(event) + if Menu then + if Menu.run(event) then + Menu = nil + collectgarbage("collect") + Dashboard.draw() + end + return 0 + end + if event == EVT_VIRTUAL_ENTER and VTXAdmin.hasModule() then + if VTXAdmin.isReady() then + VTXAdmin.syncDesiredFromState() + end + Menu = loader("/SCRIPTS/ELRS/vtx/lcd/menu.lua", VTXAdmin, PresetsStorage, labels) + Menu.run(0) + return 0 + end + Dashboard.draw() + return 0 +end + +return { run = run, background = background } diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua index 14bdf45..5e05554 100644 --- a/src/WIDGETS/ELRSTelemetry/main.lua +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -17,7 +17,7 @@ local function create(zone, options) _crsfSingleton = getCRSF() end if not _elrsTelemetrySingleton then - local getTelemetry = loadScript(table.concat({ "/WIDGETS/", name, "/telemetry.lua" })) + local getTelemetry = loadScript("/SCRIPTS/ELRS/telemetry/state.lua") ---@diagnostic disable-next-line: need-check-nil _elrsTelemetrySingleton = getTelemetry(_crsfSingleton) end diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index bb2c965..d69b87e 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1,463 +1,12 @@ --------------------------------------------------------------------------- --- VTX Administrator Widget - Core -- --- Loaded via loadScript() from ELRSVTXAdmin/main.lua. State is per -- --- instance, except the shared PresetsStorage (its latch dedupes 6POS). -- +-- VTX Administrator Widget - Loadable -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua; screen UIs over -- +-- a per-instance VTXAdmin core (/SCRIPTS/ELRS/vtx/admin.lua). -- --------------------------------------------------------------------------- local zone, options, crsf, CRSFSession, PresetsStorage = ... --- ============================================================================ --- VTXAdmin: client of the module's VTX Administrator --- ============================================================================ - --- Passive: also sees fields sibling instances requested -local session - -local VTXAdmin = { - PHASE_INIT = 0, - PHASE_NO_MODULE = 1, - PHASE_DISCOVER_ROOT = 2, - PHASE_DISCOVER_CHILDREN = 3, - PHASE_DISCOVER_VTX = 4, - PHASE_READY = 5, - PHASE_SENDING = 6, - - phase = 0, -- PHASE_INIT - - -- Detects suspension by a standalone tool - lastTick = 0, - - DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) - - -- Discovered by name at runtime - ids = { - folder = nil, - band = nil, - channel = nil, - power = nil, - pitmode = nil, - send = nil, - }, - - -- Band 0 has no letter: "Off" for the VTX, "--" for an unused preset - BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, - BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, - - statusText = "Initializing...", - - -- Mutated in place; UI closures hold references - state = { - band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L - bandLetter = "?", - channel = 0, - power = 0, - pitmode = false, -- true only when pit mode is confirmed on - pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch - }, - - -- Edited in full screen; mutated in place - desired = { - band = 5, -- Raceband - channel = 1, - power = 0, - pitmode = 0, - }, -} - ---- Parse "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])" into state. ---- PITMODE is "P", or an aux label plus up/down arrow when switch-bound. ---- Band Off drops the suffix, power "-" drops power and pit mode. ---- Aux binding sets pitmodeAux only: the name never has the switch position. -local function parseFolderName(name) - local s = VTXAdmin.state - local content = string.match(name, "%((.+)%)") - if not content then - s.band = 0 - s.bandLetter = "Off" - s.channel = 0 - s.power = 0 - s.pitmode = false - s.pitmodeAux = nil - return true - end - - local parts = {} - for part in string.gmatch(content, "([^:]+)") do - parts[#parts + 1] = part - end - if #parts < 2 then - return false - end - - s.bandLetter = parts[1] - s.band = VTXAdmin.BAND_VALUES[parts[1]] or 0 - s.channel = tonumber(parts[2]) or 0 - s.power = tonumber(parts[3]) or 0 - if #parts < 4 then - s.pitmode = false - s.pitmodeAux = nil - elseif parts[4] == "P" then - s.pitmode = true - s.pitmodeAux = nil - else - -- Strip the trailing arrow (multi-byte CHAR_UP/CHAR_DOWN) - s.pitmode = false - local part = parts[4] - if CHAR_UP and string.sub(part, -#CHAR_UP) == CHAR_UP then - s.pitmodeAux = string.sub(part, 1, -#CHAR_UP - 1) - elseif CHAR_DOWN and string.sub(part, -#CHAR_DOWN) == CHAR_DOWN then - s.pitmodeAux = string.sub(part, 1, -#CHAR_DOWN - 1) - else - s.pitmodeAux = string.sub(part, 1, -2) - end - end - return true -end - -function VTXAdmin.isTuned() - return VTXAdmin.isActive() and VTXAdmin.state.band > 0 -end - -function VTXAdmin.isDisabled() - return VTXAdmin.isActive() and VTXAdmin.state.band == 0 -end - ---- Power "-" drops power and pit mode from the folder name. -function VTXAdmin.hasPower() - return VTXAdmin.isTuned() and VTXAdmin.state.power > 0 -end - -function VTXAdmin.syncDesiredFromState() - local s = VTXAdmin.state - local d = VTXAdmin.desired - d.band = s.band - d.channel = s.channel - d.power = s.power - d.pitmode = s.pitmode and 1 or 0 -end - --- ============================================================================ --- VTXAdmin: state machine query helpers --- ============================================================================ - -function VTXAdmin.isReady() - return VTXAdmin.phase == VTXAdmin.PHASE_READY -end - -function VTXAdmin.isSending() - return VTXAdmin.phase == VTXAdmin.PHASE_SENDING -end - -function VTXAdmin.isActive() - return VTXAdmin.phase == VTXAdmin.PHASE_READY or VTXAdmin.phase == VTXAdmin.PHASE_SENDING -end - ---- Presets are local, so they show before discovery finishes. -function VTXAdmin.hasModule() - return VTXAdmin.phase ~= VTXAdmin.PHASE_NO_MODULE -end - --- ============================================================================ --- VTXAdmin: field handler (session onFieldUpdate callback) --- ============================================================================ - -local function onField(field) - local fieldId = field.id - local fieldName = field.name - - if VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_ROOT then - if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then - -- Session auto-queues the root children - VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_CHILDREN - VTXAdmin.statusText = "Discovering fields..." - end - elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_CHILDREN then - if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then - VTXAdmin.ids.folder = fieldId - parseFolderName(fieldName) - session:loadFolder(fieldId) - VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_VTX - VTXAdmin.statusText = "Loading VTX fields..." - elseif not session:isLoading() and VTXAdmin.ids.folder == nil then - VTXAdmin.statusText = "VTX Admin not found" - end - elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_VTX then - if fieldName == "Band" or fieldName == "Band/Enable" then - VTXAdmin.ids.band = fieldId - elseif fieldName == "Channel" then - VTXAdmin.ids.channel = fieldId - elseif fieldName == "Pwr Lvl" then - VTXAdmin.ids.power = fieldId - elseif fieldName == "Pitmode" then - VTXAdmin.ids.pitmode = fieldId - elseif fieldName == "Send VTx" then - VTXAdmin.ids.send = fieldId - end - - if not session:isLoading() then - if - VTXAdmin.ids.band - and VTXAdmin.ids.channel - and VTXAdmin.ids.power - and VTXAdmin.ids.pitmode - and VTXAdmin.ids.send - then - VTXAdmin.phase = VTXAdmin.PHASE_READY - VTXAdmin.statusText = "" - VTXAdmin.syncDesiredFromState() - else - VTXAdmin.statusText = "VTX fields incomplete" - end - end - elseif VTXAdmin.phase == VTXAdmin.PHASE_READY then - if fieldId == VTXAdmin.ids.folder then - parseFolderName(fieldName) - end - end -end - -session = CRSFSession.new({ - acceptUnsolicited = true, - responseTimeout = 50, -- always the local TX module - onFieldUpdate = onField, -}) - --- ============================================================================ --- VTXAdmin: 6POS quick-change and push-trigger automation --- ============================================================================ - -local function mapTo6Pos(value) - local pos = math.floor((value + 1024) * 6 / 2049) + 1 - if pos < 1 then - pos = 1 - end - if pos > 6 then - pos = 6 - end - return pos -end - ---- Shared latch: one write per edge per radio, not per instance. -local function process6Pos() - if not PresetsStorage.enabled then - return - end - if PresetsStorage.source == 0 then - return - end - - local value = getValue(PresetsStorage.source) - if value == nil then - return - end - - local latch = PresetsStorage.latch - local pos = mapTo6Pos(value) - local now = getTime() - - -- Debounce; shared, as the source is radio state - if pos ~= latch.stablePos then - latch.stablePos = pos - latch.stableTime = now - return - end - if now - latch.stableTime < VTXAdmin.DEBOUNCE then - return - end - - -- First reading after boot or a source change: record it, don't retune, - -- unless Apply at Startup. The switch may boot on position 1. - if PresetsStorage.source ~= latch.sourceSeen then - latch.sourceSeen = PresetsStorage.source - latch.lastCollection = PresetsStorage.collection - if not PresetsStorage.applyOnStart then - latch.lastPos = pos - return - end - latch.lastPos = -1 - end - - -- Latch only once writable, or the edge is lost for every instance - if not VTXAdmin.isReady() then - return - end - - -- A collection change also retunes - if pos == latch.lastPos and PresetsStorage.collection == latch.lastCollection then - return - end - -- Push the startup apply: the link may already be up - local first = latch.lastPos == -1 - latch.lastPos = pos - latch.lastCollection = PresetsStorage.collection - - -- Unused slot: leave the VTX alone, no retry - local preset = PresetsStorage.items[pos] - if not preset or preset.band == 0 then - return - end - - VTXAdmin.applyPreset(preset.band, preset.channel) - if PresetsStorage.autoPushVtx or first then - VTXAdmin.pushToVtx() - end -end - ---- Rising edge on pushSource sends config; one push per radio. -local function processPushTrigger() - if PresetsStorage.autoPushVtx then - return - end - if PresetsStorage.pushSource == 0 then - return - end - - local val = getValue(PresetsStorage.pushSource) - if val == nil then - return - end - - local latch = PresetsStorage.latch - local high = val > 0 - - -- New source: adopt its level without firing (also seeds at boot) - if PresetsStorage.pushSource ~= latch.pushSourceSeen then - latch.pushSourceSeen = PresetsStorage.pushSource - latch.pushLastHigh = high - return - end - - -- Latch only once sendable, or the edge is lost - if not VTXAdmin.isReady() and not VTXAdmin.isSending() then - return - end - - local wasHigh = latch.pushLastHigh - latch.pushLastHigh = high - - if high and not wasHigh then - VTXAdmin.pushToVtx() - end -end - --- ============================================================================ --- VTXAdmin: state machine tick --- ============================================================================ - -function VTXAdmin.tick() - local now = getTime() - - -- Resumed after >1 s: a tool may have changed config, so read back once. - -- Never poll: each read replaces an RC frame. - if VTXAdmin.lastTick > 0 and now - VTXAdmin.lastTick > 100 and VTXAdmin.phase == VTXAdmin.PHASE_READY then - session:refreshField(VTXAdmin.ids.folder, 0, 3) - end - VTXAdmin.lastTick = now - - if VTXAdmin.phase == VTXAdmin.PHASE_INIT then - if crsf.hasCrsfModule() then - VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_ROOT - VTXAdmin.statusText = "Discovering..." - session:reloadAll() - else - VTXAdmin.phase = VTXAdmin.PHASE_NO_MODULE - VTXAdmin.statusText = "No CRSF module" - end - elseif VTXAdmin.phase == VTXAdmin.PHASE_SENDING and not session:isWriting() then - print("VTXAdmin: write queue drained") - VTXAdmin.phase = VTXAdmin.PHASE_READY - -- ~100 ms for the module to apply the change - session:refreshField(VTXAdmin.ids.folder, 10, 3) - end - - session:tick() - - -- After the pump, so queued writes go out next tick - process6Pos() - processPushTrigger() -end - --- ============================================================================ --- VTXAdmin: write queue builder --- ============================================================================ - ---- Write changed fields; pushToVtx() sends them to the VTX. -function VTXAdmin.writeConfig() - if not VTXAdmin.isReady() then - print("VTXAdmin: writeConfig() skipped - not ready") - return - end - - local s = VTXAdmin.state - local d = VTXAdmin.desired - - print(table.concat({ - "VTXAdmin: writeConfig() desired: band=", - d.band, - " ch=", - d.channel, - " pwr=", - d.power, - " pit=", - tostring(d.pitmode), - })) - print(table.concat({ - "VTXAdmin: writeConfig() current: band=", - s.band, - " ch=", - s.channel, - " pwr=", - s.power, - " pit=", - tostring(s.pitmode), - })) - - local wrote = 0 - if d.band ~= s.band then - session:writeField({ id = VTXAdmin.ids.band, value = d.band }) - wrote = wrote + 1 - end - if d.channel ~= s.channel then - session:writeField({ id = VTXAdmin.ids.channel, value = d.channel }) - wrote = wrote + 1 - end - if d.power ~= s.power then - session:writeField({ id = VTXAdmin.ids.power, value = d.power }) - wrote = wrote + 1 - end - - local desiredPit = d.pitmode - local currentPit = s.pitmode and 1 or 0 - if desiredPit ~= currentPit then - session:writeField({ id = VTXAdmin.ids.pitmode, value = desiredPit }) - wrote = wrote + 1 - end - - print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) - - if wrote > 0 then - VTXAdmin.phase = VTXAdmin.PHASE_SENDING - end -end - ---- Re-sync first: desired may hold stale power/pitmode. -function VTXAdmin.applyPreset(band, channel) - VTXAdmin.syncDesiredFromState() - VTXAdmin.desired.band = band - VTXAdmin.desired.channel = channel - VTXAdmin.writeConfig() -end - -function VTXAdmin.pushToVtx() - if not VTXAdmin.isReady() and VTXAdmin.phase ~= VTXAdmin.PHASE_SENDING then - print("VTXAdmin: pushToVtx() skipped - not ready") - return - end - - print("VTXAdmin: pushToVtx() - sending Send VTx command") - session:writeField({ id = VTXAdmin.ids.send, value = crsf.CONST.CMD_CLICK }) - VTXAdmin.phase = VTXAdmin.PHASE_SENDING -end +local VTXAdmin = loadScript("/SCRIPTS/ELRS/vtx/admin.lua")(crsf, CRSFSession, PresetsStorage) -- ============================================================================ -- Display components @@ -509,7 +58,6 @@ local wgt = { } function wgt.background() - session:drain() VTXAdmin.tick() end diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua index 5455c4c..3436a5c 100644 --- a/src/WIDGETS/ELRSVTXAdmin/main.lua +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -24,7 +24,7 @@ local function create(zone, options) local getFileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua") ---@diagnostic disable-next-line: need-check-nil local fileStorage = getFileStorage() - local getPresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua") + local getPresetsStorage = loadScript("/SCRIPTS/ELRS/vtx/presets.lua") ---@diagnostic disable-next-line: need-check-nil _vtxPresetsStorage = getPresetsStorage(fileStorage) end