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..c6f4b25 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,10 @@ The telemetry widget (`ExpressLRS Telemetry`) displays real-time link statistics 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..1ca6a8c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -68,8 +68,15 @@ The telemetry widget, `WIDGETS/ELRSTelemetry/`: | `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. +`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 @@ -88,17 +95,20 @@ 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/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 diff --git a/edgetx.yml b/edgetx.yml index 0420184..37a71a1 100644 --- a/edgetx.yml +++ b/edgetx.yml @@ -17,6 +17,7 @@ package: - screenshots/tool_bind_bw.png - screenshots/widget_telemetry_fullscren.png - screenshots/widget_vtxadmin_fullscreen.png + - screenshots/telemetry_vtxadmin_bw.png - screenshots/widgets.png keywords: - expresslrs @@ -29,6 +30,8 @@ package: libraries: - name: ELRS path: SCRIPTS/ELRS + exclude: + - presets.txt - name: CRSFSimulator path: SCRIPTS/CRSFSimulator @@ -57,5 +60,9 @@ widgets: path: WIDGETS/ELRSVTXAdmin depends: - ELRS - exclude: - - presets.txt + +telemetry: + - name: ELRVTX + path: SCRIPTS/TELEMETRY/ELRVTX.lua + depends: + - ELRS 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/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/vtx/admin.lua b/src/SCRIPTS/ELRS/vtx/admin.lua new file mode 100644 index 0000000..52f5abf --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/admin.lua @@ -0,0 +1,462 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Core -- +-- Client of the module's VTX Admin folder, plus 6POS automation. -- +--------------------------------------------------------------------------- + +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..e0800d4 --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/lcd/dashboard.lua @@ -0,0 +1,108 @@ +--------------------------------------------------------------------------- +-- B&W VTX Admin Dashboard -- +--------------------------------------------------------------------------- + +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..78aae84 --- /dev/null +++ b/src/SCRIPTS/ELRS/vtx/lcd/menu.lua @@ -0,0 +1,299 @@ +--------------------------------------------------------------------------- +-- B&W VTX Admin Menu -- +--------------------------------------------------------------------------- + +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 + +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 + +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 90% rename from src/WIDGETS/ELRSVTXAdmin/presets_storage.lua rename to src/SCRIPTS/ELRS/vtx/presets.lua index 9249021..5d7b6af 100644 --- a/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua +++ b/src/SCRIPTS/ELRS/vtx/presets.lua @@ -1,12 +1,12 @@ --------------------------------------------------------------------------- --- 6POS Preset Storage -- --- Loaded via loadScript() from ELRSVTXAdmin/main.lua with (FileStorage) -- --- and shared by every widget instance. -- +-- VTX Admin 6POS Preset Storage -- --------------------------------------------------------------------------- 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 +17,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 +124,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/ELRVTX.lua b/src/SCRIPTS/TELEMETRY/ELRVTX.lua new file mode 100644 index 0000000..4d68805 --- /dev/null +++ b/src/SCRIPTS/TELEMETRY/ELRVTX.lua @@ -0,0 +1,61 @@ +--------------------------------------------------------------------------- +-- 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")() + +local crsf = loader("/SCRIPTS/ELRS/crsf.lua") +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/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua index bb2c965..ba513f1 100644 --- a/src/WIDGETS/ELRSVTXAdmin/loadable.lua +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -1,463 +1,10 @@ --------------------------------------------------------------------------- --- 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 -- --------------------------------------------------------------------------- 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 +56,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