diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e17fe93 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +jobs: + check: + name: Lint and Typecheck + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - name: Cache cargo + uses: actions/cache@v5 + with: + path: ~/.cargo + key: cargo-stylua-${{ runner.os }} + + - name: Install tools + run: make install-tools + + - name: Run checks + run: make check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d979cc3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.luac +color/WIDGETS/ELRSVTXAdmin/presets.txt +bin/ +.claude/plans/ diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000..50982c3 --- /dev/null +++ b/.luarc.json @@ -0,0 +1,16 @@ +{ + "runtime.version": "Lua 5.3", + "runtime.builtin": { + "os": "disable", + "debug": "disable", + "package": "disable", + "coroutine": "disable", + "utf8": "disable", + "io": "disable", + "bit32": "disable" + }, + "workspace.library": ["edgetx-lua-stdlib"], + "workspace.ignoreDir": ["bin", "edgetx-lua-stdlib"], + "workspace.checkThirdParty": false, + "diagnostics.disable": ["lowercase-global"] +} diff --git a/.stylua.toml b/.stylua.toml new file mode 100644 index 0000000..cee468f --- /dev/null +++ b/.stylua.toml @@ -0,0 +1,7 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" +collapse_simple_statement = "Never" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b7f3d9a --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +SRC_DIR := src + +LUALS_VERSION := 3.17.1 +LUALS_DIR := bin/lua-language-server +LUALS := $(LUALS_DIR)/bin/lua-language-server +LUALS_URL := https://github.com/LuaLS/lua-language-server/releases/download/$(LUALS_VERSION)/lua-language-server-$(LUALS_VERSION)-linux-x64.tar.gz + +.PHONY: help install-tools install-stylua install-luals format format-check typecheck check sync push + +help: + @echo "Usage: make " + @echo "" + @echo " install-tools Install stylua and lua-language-server" + @echo " install-stylua Install stylua (via cargo)" + @echo " install-luals Install lua-language-server" + @echo " format Format Lua files with stylua" + @echo " format-check Check formatting without modifying files" + @echo " typecheck Run lua-language-server type checking" + @echo " check Run format-check and typecheck" + @echo " sync Sync source files to EdgeTX simulator SD card" + @echo " push Install package to EdgeTX radio and eject" + +install-stylua: + @command -v cargo >/dev/null 2>&1 || { echo "cargo is required (install Rust: https://rustup.rs)"; exit 1; } + cargo install stylua --features lua53 + +install-luals: + mkdir -p $(LUALS_DIR) + curl -fSL $(LUALS_URL) | tar xz -C $(LUALS_DIR) + +install-tools: install-stylua install-luals + +format: + stylua $(SRC_DIR) + +format-check: + stylua --check $(SRC_DIR) + +typecheck: + $(LUALS) --check . + +check: format-check typecheck + +sync: + edgetx-cli dev sync ../edgetx-sdcard + +push: + edgetx-cli pkg install . --eject diff --git a/README.md b/README.md index 12a229d..8a678fe 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,127 @@ +# ExpressLRS Lua Scripts -`elrs.lua` works with all versions of ExpressLRS from v2.0 to current. There is no requirement to use an e.g. elrsV3.lua for 3.x, just use elrs.lua. +Lua configuration tool for ExpressLRS on EdgeTX radios. Works on both black & white LCD and color LCD radios. -## Old ELRS.lua +The package also includes two color-LCD widgets: the **ELRS Telemetry Widget** and the **VTX Administrator Widget**. -When copying the lua to you handset, delete any old versions such as ELRS.lua, elrsV2.lua, or elrsV3.lua. The version-labeled filenames have been obsoleted. +## Features -### Downloading from Github: -Click the file link above, find the "Raw" button near the top of that page. Right-click, Save link as.., copy the .lua file into the /SCRIPTS/TOOLS directory of the SD card on your handset. +- Configure packet rate, telemetry ratio, switch mode, model match, antenna mode, TX power, WiFi connectivity, and more +- Compatible with **ExpressLRS v3.0+** -### Downloading from Configurator: -Use the button shown in the image below to download the .lua script into the /SCRIPTS/TOOLS directory of the SD card on your handset. -![downloadlua](https://user-images.githubusercontent.com/68074253/129203116-c1234719-3e8c-4cbf-a391-b7fb8dc0262d.png) +## Installation + +Copy the contents of the `src/` directory to the **root** of your radio's SD card, preserving the directory structure. Delete any old ELRS scripts (`ELRS.lua`, `elrsV2.lua`, `elrsV3.lua`, `expresslrs.lua` and their `.luac` counterparts) from `SCRIPTS/TOOLS/`. + +When done, your SD card should contain: + +``` +SCRIPTS/ + ELRS/ + crsf.lua -- shared CRSF protocol library + TOOLS/ + ExpressLRS/ + main.lua -- entry point + protocol.lua -- CRSF protocol handling + navigation.lua -- folder navigation + shim.lua -- BW compatibility shim + ui/ + lvgl.lua -- color LCD UI (LVGL) + lcd.lua -- black & white LCD UI +WIDGETS/ + ELRSTelemetry/ + main.lua + loadable.lua + ui/ + ... + ELRSVTXAdmin/ + main.lua + loadable.lua + presets.txt + ui/ + ... +``` + +The shared library `SCRIPTS/ELRS/crsf.lua` is required by both widgets. + +### Install with edgetx-cli + +You can also install this package using [edgetx-cli](https://github.com/jurgelenas/edgetx-cli): + +```sh +edgetx-cli pkg install ExpressLRS/Lua-Scripts@unified-lua-lsp +``` + +Use the `--eject` flag to automatically unmount the SD card after installation. + +## ExpressLRS Configuration Tool + +The main tool (`SCRIPTS/TOOLS/ExpressLRS/`) lets you configure your ExpressLRS transmitter and receiver settings directly from your radio. + +ExpressLRS Configuration Tool
+ +ExpressLRS Configuration Tool + +### Architecture + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and run-loop orchestrator | +| `protocol.lua` | CRSF frame parsing, device discovery, parameter read/write | +| `navigation.lua` | Folder and device navigation stack | +| `shim.lua` | Polyfills for BW radios missing standard Lua functions | +| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) | +| `ui/lcd.lua` | BW LCD interface (text cursor, popups) | + +## Widgets + +Both widgets running side-by-side on the home screen: + +ELRS Widgets + +## ELRS Telemetry Widget + +The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). + +ELRS Telemetry Widget + +## VTX Administrator Widget + +The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. + +VTX Administrator Widget + +## CRSF Simulator (Testing) + +The `test/` directory contains a CRSF protocol simulator for development and testing without real hardware. + +**File:** `test/SCRIPTS/CRSFSimulator/csrfsimulator.lua` + +The simulator provides a packet-level mock of `crossfireTelemetryPop` and `crossfireTelemetryPush`, allowing the ELRS tool to exercise the full communication flow (device discovery, parameter loading, value writes, ELRS status) inside the EdgeTX simulator. Multiple scenarios are available to simulate different states such as normal operation, disconnected links, model mismatch, and more. + +### How it works + +When the tool detects it is running in the EdgeTX simulator (version string ends with `-simu`), `main.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` and patches the protocol's `pop`, `push`, and `hasCrsfModule` functions with the mock implementations. + +To use the simulator, copy the `test/` directory contents onto the SD card alongside `src/` so that `SCRIPTS/CRSFSimulator/csrfsimulator.lua` is present. The simulator is ignored on real hardware. + +### Scenarios + +The simulator supports multiple test scenarios, configurable via the `config.scenario` variable at the top of the file: + +| Scenario | Description | +|----------|-------------| +| `normal` | TX + RX connected. Happy path with full telemetry and all parameters. | +| `no_telemetry` | TX present but no RX telemetry. Shows "No telemetry" state. | +| `reconnect` | Starts disconnected, transitions to connected after ~5 seconds. | +| `model_mismatch` | TX + RX connected with Model ID mismatch flag. Triggers warning dialog. | +| `armed` | TX + RX connected with "is Armed" warning flag. | +| `slow_loading` | Parameter reads delayed by ~2 seconds each. Tests loading UI states. | +| `no_module` | No CRSF module found. Triggers "No Module Found" error dialog. | + +## Compatibility + +| Radio type | Firmware | ExpressLRS | +|------------|----------|------------| +| Black & white LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | +| Color LCD | EdgeTX 2.12-rc4+ or 3.0+ | v3.0+ | diff --git a/edgetx-lua-stdlib/audio.d.lua b/edgetx-lua-stdlib/audio.d.lua new file mode 100644 index 0000000..fec57cd --- /dev/null +++ b/edgetx-lua-stdlib/audio.d.lua @@ -0,0 +1,56 @@ +---@meta + +-- EdgeTX audio and haptic playback functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Play an audio file from the SD card. +--- @since 2.0.0 +---@param path string Audio file path (e.g. "/SOUNDS/en/hello.wav") +---@param volume? integer Volume override (1-5, omit to use system setting) +function playFile(path, volume) end + +--- Play a number with optional unit announcement. +--- @since 2.0.0 +---@param value integer Number to play +---@param unit integer Unit constant (UNIT_*) +---@param attributes? integer +---@param volume? integer Volume override (1-5, omit to use system setting) +function playNumber(value, unit, attributes, volume) end + +--- Play a duration value (hours, minutes, seconds). +--- @since 2.0.0 +---@param duration integer Seconds +---@param playTime? integer Non-zero to announce as time of day +---@param volume? integer Volume override (1-5, omit to use system setting) +function playDuration(duration, playTime, volume) end + +--- Play a tone with configurable frequency, duration, and pattern. +--- @since 2.1.0 +---@param frequency integer Hz +---@param duration integer ms +---@param pause integer ms of silence after tone +---@param flags? integer 0 or PLAY_NOW +---@param freqIncr? integer Frequency increment per repeat +---@param volume? integer Volume override (1-5, omit to use system setting) +function playTone(frequency, duration, pause, flags, freqIncr, volume) end + +--- Generate haptic feedback. +--- @since 2.2.0 +---@param duration integer ms +---@param pause? integer ms of silence after haptic +---@param flags? integer 0 or PLAY_NOW +function playHaptic(duration, pause, flags) end + +--- Flush the audio queue, stopping all current and pending audio playback. +function flushAudio() end + +-- ========================================================================== +-- Audio constants +-- ========================================================================== + +---@type integer +PLAY_NOW = 0 +---@type integer +PLAY_BACKGROUND = 0 +---@type integer +TIMEHOUR = 0 diff --git a/edgetx-lua-stdlib/bit32.d.lua b/edgetx-lua-stdlib/bit32.d.lua new file mode 100644 index 0000000..4ce0156 --- /dev/null +++ b/edgetx-lua-stdlib/bit32.d.lua @@ -0,0 +1,67 @@ +---@meta + +-- Lua 5.2 bit32 library (available in EdgeTX runtime) +-- Custom definition to avoid deprecation warnings from lua-language-server. +-- The built-in bit32 is disabled in .luarc.json because LuaLS marks it +-- deprecated under Lua 5.3, but EdgeTX's runtime still requires it. + +---@class bit32lib +bit32 = {} + +---@param ... integer +---@return integer +function bit32.band(...) end + +---@param ... integer +---@return integer +function bit32.bor(...) end + +---@param ... integer +---@return integer +function bit32.bxor(...) end + +---@param x integer +---@return integer +function bit32.bnot(x) end + +---@param ... integer +---@return boolean +function bit32.btest(...) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.lshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.rshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.arshift(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.lrotate(x, disp) end + +---@param x integer +---@param disp integer +---@return integer +function bit32.rrotate(x, disp) end + +---@param n integer +---@param field integer +---@param width? integer +---@return integer +function bit32.extract(n, field, width) end + +---@param n integer +---@param v integer +---@param field integer +---@param width? integer +---@return integer +function bit32.replace(n, v, field, width) end diff --git a/edgetx-lua-stdlib/bitmap.d.lua b/edgetx-lua-stdlib/bitmap.d.lua new file mode 100644 index 0000000..628a965 --- /dev/null +++ b/edgetx-lua-stdlib/bitmap.d.lua @@ -0,0 +1,67 @@ +---@meta + +-- EdgeTX bitmap module (color LCD only) +-- Source: edgetx/radio/src/lua/api_colorlcd.cpp + +---@class BitmapObject + +---@class bitmap +bitmap = {} + +--- Load a bitmap from the SD card. Bitmaps should be loaded only once; store the +--- returned object for later drawing with lcd.drawBitmap(). If loading fails the +--- resulting bitmap object will have width and height of zero. +--- Loading can fail if the file is not found, the image is invalid, +--- the system is low on memory, or combined Lua bitmap usage exceeds the limit. +--- @since 2.2.0 +---@param path string Full path to the bitmap on SD card (e.g. "/IMAGES/test.bmp") +---@return BitmapObject bmp +function bitmap.open(path) end + +--- Return width and height of a bitmap object. +--- @since 2.2.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@return integer width Width in pixels +---@return integer height Height in pixels +function bitmap.getSize(bmp) end + +--- Return a new resized copy of a bitmap. +--- @since 2.8.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@param width integer New width in pixels +---@param height integer New height in pixels +---@return BitmapObject|nil resized nil if input bitmap is invalid +function bitmap.resize(bmp, width, height) end + +--- Convert a bitmap to an 8-bit mask for use with lcd.drawBitmapPattern(). +--- @since 2.8.0 +---@param bmp BitmapObject Bitmap previously opened with bitmap.open() +---@return string mask Binary mask data +function bitmap.toMask(bmp) end + +--- @deprecated Use `bitmap` module instead. +---@class Bitmap +Bitmap = {} + +--- @deprecated Use `bitmap.open` instead. +---@param path string Full path to the bitmap on SD card +---@return BitmapObject bmp +function Bitmap.open(path) end + +--- @deprecated Use `bitmap.getSize` instead. +---@param bmp BitmapObject +---@return integer width +---@return integer height +function Bitmap.getSize(bmp) end + +--- @deprecated Use `bitmap.resize` instead. +---@param bmp BitmapObject +---@param width integer +---@param height integer +---@return BitmapObject|nil resized +function Bitmap.resize(bmp, width, height) end + +--- @deprecated Use `bitmap.toMask` instead. +---@param bmp BitmapObject +---@return string mask +function Bitmap.toMask(bmp) end diff --git a/edgetx-lua-stdlib/dir.d.lua b/edgetx-lua-stdlib/dir.d.lua new file mode 100644 index 0000000..90d243f --- /dev/null +++ b/edgetx-lua-stdlib/dir.d.lua @@ -0,0 +1,58 @@ +---@meta + +-- EdgeTX filesystem module +-- Source: edgetx/radio/src/lua/api_filesystem.cpp + +---@class dir +dir = {} + +--- Return an iterator listing all file and directory names in a directory. +--- @since 2.5.0 +---@param path? string Working directory +---@return fun():string|nil iterator Returns filenames one at a time +function dir.dir(path) end + +--- Check the existence of a file or directory. Returns nil if not found. +--- @since 2.5.0 +---@param path? string Path to the object +---@return {size: integer, attrib: integer, time: {year: integer, mon: integer, day: integer, hour: integer, hour12: integer, min: integer, sec: integer, suffix: string}}|nil stat +function dir.fstat(path) end + +--- Delete a file or directory. +--- @since 2.9.0 +---@param path? string Path to file or directory to delete +---@return integer result FRESULT (0=OK, 4=File not found, 5=Path not found, 6=Path invalid) +function dir.del(path) end + +--- Change the working directory. +--- @since 2.3.0 +---@param path? string New working directory +function dir.chdir(path) end + +--- Create a directory. +--- @since 2.11.0 +---@param path string Directory path to create +---@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) +function dir.mkdir(path) end + +--- Rename or move a file or directory. The new parent directory must already exist. +--- @since 2.11.0 +---@param oldPath string Current path +---@param newPath string New path +---@return integer result FRESULT (0=OK, 6=Path invalid, 8=Directory exists) +function dir.rename(oldPath, newPath) end + +-- ========================================================================== +-- File attribute constants (for dir.fstat attrib field) +-- ========================================================================== + +---@type integer +AM_RDO = 0 +---@type integer +AM_HID = 0 +---@type integer +AM_SYS = 0 +---@type integer +AM_DIR = 0 +---@type integer +AM_ARC = 0 diff --git a/edgetx-lua-stdlib/edgetx.d.lua b/edgetx-lua-stdlib/edgetx.d.lua new file mode 100644 index 0000000..f719577 --- /dev/null +++ b/edgetx-lua-stdlib/edgetx.d.lua @@ -0,0 +1,396 @@ +---@meta + +-- EdgeTX global functions and constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +-- ========================================================================== +-- System information +-- ========================================================================== + +--- Return EdgeTX version information. +--- @since 2.0.0 +---@return string version Full version string (e.g. "2.11.0") +---@return string radio Radio type string (e.g. "tx16s-2.11.0") +---@return integer major Major version number +---@return integer minor Minor version number +---@return integer revision Revision number +---@return string osname OS name (e.g. "EdgeTX") +function getVersion() end + +--- Return general radio settings. +--- @since 2.0.0 +---@return {battWarn: number, battMin: number, battMax: number, imperial: integer, language: string, voice: string, gtimer: integer} settings +function getGeneralSettings() end + +--- Return radio global timer data. +--- @since 2.3.0 +---@return {total: integer, session: integer, throttle: integer, throttlepct: integer} +function getGlobalTimer() end + +--- Reset one or all radio global timers. +--- @since 2.3.0 +---@param which? string "total"|"session"|"throttle"|"throttlepct" (default "total") +function resetGlobalTimer(which) end + +--- Return rotary encoder speed. +---@return integer speed ROTENC_LOWSPEED, ROTENC_MIDSPEED, or ROTENC_HIGHSPEED +function getRotEncSpeed() end + +--- Return rotary encoder mode. +---@return integer mode +function getRotEncMode() end + +--- Return the current stick mode (1-4). +---@return integer mode +function getStickMode() end + +-- ========================================================================== +-- Source and value functions +-- ========================================================================== + +--- Return the current value of a source. GPS and cells return tables. +--- @since 2.0.0 +---@param source integer|string Source index or name +---@return integer|number|string|table|nil value +function getValue(source) end + +--- Return the current output value for a channel (after all processing). +---@param index integer Channel index (0-based) +---@return integer value +function getOutputValue(index) end + +--- Return the value of a source with freshness information. +---@param source integer|string Source index or name +---@return integer|number|string|table|nil value +---@return boolean isCurrent True if telemetry stream is currently active +---@return boolean isFresh True if data has been recently updated +function getSourceValue(source) end + +--- Return information about a field/source. Returns nil if source not found. +--- For telemetry sources, the `unit` field is also included. +--- @since 2.0.8 +---@param source integer|string Source index or name +---@return {id: integer, name: string, desc: string, unit?: integer}|nil info +function getFieldInfo(source) end + +--- Return information about a source. Same as getFieldInfo(). +---@param source integer|string Source index or name +---@return {id: integer, name: string, desc: string, unit?: integer}|nil info +function getSourceInfo(source) end + +--- Get the source index for a named source. +---@param name string Source name +---@return integer|nil index +function getSourceIndex(name) end + +--- Get the display name for a source index. +---@param index integer Source index +---@return string|nil name +function getSourceName(index) end + +--- Return an iterator over sources. Optional first/last to limit range. +---@param first? integer Starting source index +---@param last? integer Ending source index +---@return function iterator +function sources(first, last) end + +-- ========================================================================== +-- Switch functions +-- ========================================================================== + +--- Set a function switch (sticky switch) state. +---@param index integer Switch index +---@param value boolean +---@return boolean bufferFull True if the command buffer is full +function setStickySwitch(index, value) end + +--- Get the value of a logical switch. +---@param index integer Logical switch index (0-based) +---@return boolean|nil value nil if index is invalid +function getLogicalSwitchValue(index) end + +--- Get information about a physical switch position. +--- @since 2.6.0 +---@param index integer Switch source index (MIXSRC_FIRST_SWITCH-based) +---@return {type: integer, isCustomisableSwitch: boolean, name: string}|nil info +function getSwitchInfo(index) end + +--- Get the switch index for a named switch position. +--- @since 2.6.0 +---@param name string Switch position name (as shown in radio menus) +---@return integer|nil index +function getSwitchIndex(name) end + +--- Get the display name for a switch index. +--- @since 2.6.0 +---@param index integer Switch index +---@return string|nil name +function getSwitchName(index) end + +--- Get the current value of a switch. +---@param index integer Switch index +---@return boolean|nil value nil if index is invalid +function getSwitchValue(index) end + +--- Return an iterator over switch positions. Optional first/last to limit range. +---@param first? integer Starting switch index +---@param last? integer Ending switch index +---@return function iterator +function switches(first, last) end + +-- ========================================================================== +-- Stick and channel functions +-- ========================================================================== + +--- Return the default stick index for a given channel. +--- @since 2.0.0 +---@param channel integer Channel number (0-3) +---@return integer stick Stick index (0-3) +function defaultStick(channel) end + +--- Return the default channel for a given stick. Returns nil if not found. +--- @since 2.0.0 +---@param stick integer Stick index (0-3) +---@return integer|nil channel +function defaultChannel(stick) end + +-- ========================================================================== +-- Flight mode +-- ========================================================================== + +--- Return the current flight mode number and name. +--- @since 2.1.7 +---@param index? integer Flight mode index (default: current active mode) +---@return integer mode +---@return string name +function getFlightMode(index) end + +-- ========================================================================== +-- UI / popup functions +-- ========================================================================== + +--- Take a screenshot and save to SD card. +function screenshot() end + +--- Show an input popup dialog with adjustable numeric value. +--- @since 2.0.0 +---@param title string +---@param event integer +---@param input integer Current value +---@param min integer Minimum value +---@param max integer Maximum value +---@return integer|string result "OK", "CANCEL", or current input value +function popupInput(title, event, input, min, max) end + +--- Show a warning popup. Returns "CANCEL" when dismissed, nil while still showing. +--- @since 2.0.0 +---@param title string +---@param event integer +---@return string|nil result "CANCEL" when dismissed, nil while still showing +function popupWarning(title, event) end + +--- Show a confirmation popup with OK/Cancel buttons. +--- @since 2.0.0 +---@overload fun(title: string, event: integer): string|nil @Deprecated 2-arg form +---@param title string +---@param message string +---@param event? integer +---@return string|nil result "OK", "CANCEL", or nil while still showing +function popupConfirmation(title, message, event) end + +--- Stop key repeat events for the given key. +---@param event integer Key event to kill +function killEvents(event) end + +-- ========================================================================== +-- Touch event constants (color LCD with touch) +-- ========================================================================== + +---@type integer +EVT_TOUCH_FIRST = 0 +---@type integer +EVT_TOUCH_BREAK = 0 +---@type integer +EVT_TOUCH_SLIDE = 0 +---@type integer +EVT_TOUCH_TAP = 0 + +-- ========================================================================== +-- Virtual event constants +-- ========================================================================== + +---@type integer +EVT_VIRTUAL_PREV = 0 +---@type integer +EVT_VIRTUAL_PREV_REPT = 0 +---@type integer +EVT_VIRTUAL_NEXT = 0 +---@type integer +EVT_VIRTUAL_NEXT_REPT = 0 +---@type integer +EVT_VIRTUAL_DEC = 0 +---@type integer +EVT_VIRTUAL_DEC_REPT = 0 +---@type integer +EVT_VIRTUAL_INC = 0 +---@type integer +EVT_VIRTUAL_INC_REPT = 0 +---@type integer +EVT_VIRTUAL_PREV_PAGE = 0 +---@type integer +EVT_VIRTUAL_NEXT_PAGE = 0 +---@type integer +EVT_VIRTUAL_MENU = 0 +---@type integer +EVT_VIRTUAL_MENU_LONG = 0 +---@type integer +EVT_VIRTUAL_ENTER = 0 +---@type integer +EVT_VIRTUAL_ENTER_LONG = 0 +---@type integer +EVT_VIRTUAL_EXIT = 0 +---@type integer +EVT_EXIT_BREAK = 0 + +-- ========================================================================== +-- Key event constants (generated per-radio, superset listed) +-- ========================================================================== + +---@type integer +EVT_ENTER_FIRST = 0 +---@type integer +EVT_ENTER_BREAK = 0 +---@type integer +EVT_ENTER_LONG = 0 +---@type integer +EVT_ENTER_REPT = 0 +---@type integer +EVT_MENU_FIRST = 0 +---@type integer +EVT_MENU_BREAK = 0 +---@type integer +EVT_MENU_LONG = 0 +---@type integer +EVT_MENU_REPT = 0 +---@type integer +EVT_TELEM_FIRST = 0 +---@type integer +EVT_TELEM_BREAK = 0 +---@type integer +EVT_TELEM_LONG = 0 +---@type integer +EVT_TELEM_REPT = 0 +---@type integer +EVT_MODEL_FIRST = 0 +---@type integer +EVT_MODEL_BREAK = 0 +---@type integer +EVT_MODEL_LONG = 0 +---@type integer +EVT_MODEL_REPT = 0 +---@type integer +EVT_SYS_FIRST = 0 +---@type integer +EVT_SYS_BREAK = 0 +---@type integer +EVT_SYS_LONG = 0 +---@type integer +EVT_SYS_REPT = 0 +---@type integer +EVT_UP_FIRST = 0 +---@type integer +EVT_UP_BREAK = 0 +---@type integer +EVT_UP_LONG = 0 +---@type integer +EVT_UP_REPT = 0 +---@type integer +EVT_DOWN_FIRST = 0 +---@type integer +EVT_DOWN_BREAK = 0 +---@type integer +EVT_DOWN_LONG = 0 +---@type integer +EVT_DOWN_REPT = 0 +---@type integer +EVT_LEFT_FIRST = 0 +---@type integer +EVT_LEFT_BREAK = 0 +---@type integer +EVT_LEFT_LONG = 0 +---@type integer +EVT_LEFT_REPT = 0 +---@type integer +EVT_RIGHT_FIRST = 0 +---@type integer +EVT_RIGHT_BREAK = 0 +---@type integer +EVT_RIGHT_LONG = 0 +---@type integer +EVT_RIGHT_REPT = 0 +---@type integer +EVT_PAGEUP_FIRST = 0 +---@type integer +EVT_PAGEUP_BREAK = 0 +---@type integer +EVT_PAGEUP_LONG = 0 +---@type integer +EVT_PAGEUP_REPT = 0 +---@type integer +EVT_PAGEDN_FIRST = 0 +---@type integer +EVT_PAGEDN_BREAK = 0 +---@type integer +EVT_PAGEDN_LONG = 0 +---@type integer +EVT_PAGEDN_REPT = 0 +---@type integer +EVT_PAGE_FIRST = 0 +---@type integer +EVT_PAGE_BREAK = 0 +---@type integer +EVT_PAGE_LONG = 0 +---@type integer +EVT_PAGE_REPT = 0 +---@type integer +EVT_PLUS_FIRST = 0 +---@type integer +EVT_PLUS_BREAK = 0 +---@type integer +EVT_PLUS_LONG = 0 +---@type integer +EVT_PLUS_REPT = 0 +---@type integer +EVT_MINUS_FIRST = 0 +---@type integer +EVT_MINUS_BREAK = 0 +---@type integer +EVT_MINUS_LONG = 0 +---@type integer +EVT_MINUS_REPT = 0 +---@type integer +EVT_SHIFT_FIRST = 0 +---@type integer +EVT_SHIFT_BREAK = 0 +---@type integer +EVT_SHIFT_LONG = 0 +---@type integer +EVT_SHIFT_REPT = 0 +---@type integer +EVT_ROT_FIRST = 0 +---@type integer +EVT_ROT_BREAK = 0 +---@type integer +EVT_ROT_LONG = 0 +---@type integer +EVT_ROT_REPT = 0 +---@type integer +EVT_ROT_LEFT = 0 +---@type integer +EVT_ROT_RIGHT = 0 +---@type integer +ROTENC_LOWSPEED = 0 +---@type integer +ROTENC_MIDSPEED = 0 +---@type integer +ROTENC_HIGHSPEED = 0 diff --git a/edgetx-lua-stdlib/hardware.d.lua b/edgetx-lua-stdlib/hardware.d.lua new file mode 100644 index 0000000..6789b6b --- /dev/null +++ b/edgetx-lua-stdlib/hardware.d.lua @@ -0,0 +1,43 @@ +---@meta + +-- EdgeTX LED and IMU hardware functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Set the color of an RGB LED in the strip. +---@param index integer LED index (0-based) +---@param red integer 0-255 +---@param green integer 0-255 +---@param blue integer 0-255 +---@return boolean success +function setRGBLedColor(index, red, green, blue) end + +--- Apply all pending RGB LED color changes. +function applyRGBLedColors() end + +--- Set or cancel a CFS (Customizable Function Switch) LED color override. +--- Call with only the name to cancel the override. +---@param name string Switch name +---@param red? integer 0-255 (omit all color args to cancel override) +---@param green? integer 0-255 +---@param blue? integer 0-255 +---@return boolean success +function setCFSLedColor(name, red, green, blue) end + +--- Set the IMU X-axis calibration parameters. +---@param offset integer Degrees (-180 to 180) +---@param range integer Degrees (0 to 180) +---@return boolean success +function setIMU_X(offset, range) end + +--- Set the IMU Y-axis calibration parameters. +---@param offset integer Degrees (-180 to 180) +---@param range integer Degrees (0 to 180) +---@return boolean success +function setIMU_Y(offset, range) end + +-- ========================================================================== +-- Hardware constants +-- ========================================================================== + +---@type integer +LED_STRIP_LENGTH = 0 diff --git a/edgetx-lua-stdlib/lcd.d.lua b/edgetx-lua-stdlib/lcd.d.lua new file mode 100644 index 0000000..a067adc --- /dev/null +++ b/edgetx-lua-stdlib/lcd.d.lua @@ -0,0 +1,528 @@ +---@meta + +-- EdgeTX LCD drawing module +-- Source: edgetx/radio/src/lua/api_colorlcd.cpp, api_stdlcd.cpp + +---@class lcd +lcd = {} + +--- Refresh the LCD screen. On color LCDs (2.4.0+) this is done automatically. +--- @since 2.0.0 +function lcd.refresh() end + +--- Clear the LCD screen. +--- @since 2.0.0 +---@param color? integer Fill color (color LCD only, defaults to COLOR_THEME_SECONDARY3) +function lcd.clear(color) end + +--- Reset the backlight timeout. +--- @since 2.3.6 +function lcd.resetBacklightTimeout() end + +--- Draw a single pixel at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param flags? integer Drawing flags +function lcd.drawPoint(x, y, flags) end + +--- Draw a straight line between two points. +--- If either endpoint is outside the LCD, the whole line will not be drawn. +--- @since 2.0.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param pattern integer SOLID or DOTTED +---@param flags? integer Drawing flags +function lcd.drawLine(x1, y1, x2, y2, pattern, flags) end + +--- Draw a rectangle outline. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param flags? integer Drawing flags +---@param thickness? integer Border thickness in pixels (color LCD only, default 1) +---@param opacity? integer 0-15 (color LCD only, default 0) +function lcd.drawRectangle(x, y, w, h, flags, thickness, opacity) end + +--- Draw a solid filled rectangle. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param flags? integer Drawing flags +---@param opacity? integer 0-15 (color LCD only, default 0) +function lcd.drawFilledRectangle(x, y, w, h, flags, opacity) end + +--- Invert a rectangular region. Color LCD only. +--- @since 2.8.0 +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param flags? integer Drawing flags +function lcd.invertRect(x, y, w, h, flags) end + +--- Draw a text string at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param text string +---@param flags? integer Drawing flags (font size, alignment, color, INVERS, BLINK, SHADOWED, etc.) +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawText(x, y, text, flags, inversColor) end + +--- Draw text inside a bounding rectangle with automatic line breaks. +--- Color LCD only. RIGHT, CENTER, VCENTER are not implemented. +--- @since 2.5.0 +---@param x integer +---@param y integer +---@param w integer +---@param h integer +---@param text string +---@param flags? integer Drawing flags +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +---@return integer x X position where text drawing ended +---@return integer y Y position where text drawing ended +function lcd.drawTextLines(x, y, w, h, text, flags, inversColor) end + +--- Get the width and height of a text string when drawn with the given flags. +--- Color LCD only. +--- @since 2.5.0 +---@param text string +---@param flags? integer Drawing flags (font size, etc.) +---@return integer width +---@return integer height +function lcd.sizeText(text, flags) end + +--- Display a value formatted as time at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param value integer Time in seconds +---@param flags? integer Drawing flags (use TIMEHOUR to show hours) +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawTimer(x, y, value, flags, inversColor) end + +--- Display a number at (x,y). Use PREC1/PREC2 flags for decimal precision. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param value integer +---@param flags? integer Drawing flags +---@param inversColor? integer Color LCD only: overrides the inverse text color when INVERS flag is set +function lcd.drawNumber(x, y, value, flags, inversColor) end + +--- Display a telemetry value at (x,y). +--- @since 2.0.6 +---@param x integer +---@param y integer +---@param source integer|string Source index or name (only telemetry sources are valid) +---@param flags? integer Drawing flags +function lcd.drawChannel(x, y, source, flags) end + +--- Draw the text representation of a switch at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param switch integer Switch index; negative number shows negated switch +---@param flags? integer Drawing flags +function lcd.drawSwitch(x, y, switch, flags) end + +--- Display the name of the corresponding source at (x,y). +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param source integer Source index +---@param flags? integer Drawing flags +function lcd.drawSource(x, y, source, flags) end + +--- Draw a horizontal gauge bar. +--- @since 2.0.0 +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param h integer Height in pixels +---@param fill integer Current fill amount +---@param maxfill integer Maximum fill value +---@param flags? integer Drawing flags +function lcd.drawGauge(x, y, w, h, fill, maxfill, flags) end + +--- Draw a .bmp pixmap at (x,y). B&W LCD only; on color LCD use lcd.drawBitmap(). +---@param x integer +---@param y integer +---@param name string File path on SD card +function lcd.drawPixmap(x, y, name) end + +--- Draw a title bar with page counter. B&W LCD only. +---@param title string +---@param page integer Current page (1-based) +---@param pages integer Total number of pages +function lcd.drawScreenTitle(title, page, pages) end + +--- Draw a combobox (dropdown selector). B&W LCD only. +---@param x integer +---@param y integer +---@param w integer Width in pixels +---@param list table Array of option strings +---@param idx integer Selected index +---@param flags integer Drawing flags +function lcd.drawCombobox(x, y, w, list, idx, flags) end + +--- Display a bitmap at (x,y). Color LCD only. +--- Omitting scale draws at 1:1 and is faster than specifying 100. +--- @since 2.2.0 +---@param bmp BitmapObject Bitmap opened with bitmap.open() +---@param x integer +---@param y integer +---@param scale? integer Percentage (0 or omit = 1:1, 50 = half, 200 = double) +function lcd.drawBitmap(bmp, x, y, scale) end + +--- Display a bitmap pattern (mask) at (x,y) using the current color. Color LCD only. +--- @since 2.8.0 +---@param mask string Binary mask data from bitmap.toMask() +---@param x integer +---@param y integer +---@param flags? integer Drawing flags (color) +function lcd.drawBitmapPattern(mask, x, y, flags) end + +--- Display a pie-shaped slice of a bitmap pattern. Color LCD only. +--- @since 2.8.0 +---@param mask string Binary mask data from bitmap.toMask() +---@param x integer +---@param y integer +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags (color) +function lcd.drawBitmapPatternPie(mask, x, y, startAngle, endAngle, flags) end + +--- Change an indexed color (theme colors or CUSTOM_COLOR). +--- Changing theme colors affects all widgets and the radio UI. Color LCD only. +--- @since 2.2.0 +---@param colorIndex integer One of the COLOR_THEME_* or CUSTOM_COLOR constants +---@param color integer RGB color value (from lcd.RGB() or another color constant) +function lcd.setColor(colorIndex, color) end + +--- Get the RGB565 color value from a color flag. Color LCD only. +--- @since 2.3.11 +---@param colorIndex integer Color constant or flags containing color +---@return integer|nil color RGB565 color value, or nil for invalid input +function lcd.getColor(colorIndex) end + +--- Create an RGB color flag from components. Color LCD only. +--- Can be called as lcd.RGB(r, g, b) or lcd.RGB(rgb). +--- @since 2.2.0 +---@overload fun(rgb: integer): integer +---@param r integer 0-255 +---@param g integer 0-255 +---@param b integer 0-255 +---@return integer color Color flag with RGB565 value +function lcd.RGB(r, g, b) end + +--- Draw a circle outline. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param flags? integer Drawing flags +function lcd.drawCircle(x, y, r, flags) end + +--- Draw a filled circle. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param flags? integer Drawing flags +function lcd.drawFilledCircle(x, y, r, flags) end + +--- Draw a triangle outline. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param x3 integer +---@param y3 integer +---@param flags? integer Drawing flags +function lcd.drawTriangle(x1, y1, x2, y2, x3, y3, flags) end + +--- Draw a filled triangle. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param x3 integer +---@param y3 integer +---@param flags? integer Drawing flags +function lcd.drawFilledTriangle(x1, y1, x2, y2, x3, y3, flags) end + +--- Draw an arc (ring segment). Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawArc(x, y, r, startAngle, endAngle, flags) end + +--- Draw a filled pie slice. Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param r integer Radius in pixels +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawPie(x, y, r, startAngle, endAngle, flags) end + +--- Draw a filled annulus (ring sector). Color LCD only. +--- @since 2.4.0 +---@param x integer Center X +---@param y integer Center Y +---@param rInner integer Inner radius +---@param rOuter integer Outer radius +---@param startAngle integer +---@param endAngle integer +---@param flags? integer Drawing flags +function lcd.drawAnnulus(x, y, rInner, rOuter, startAngle, endAngle, flags) end + +--- Draw a line clipped to a rectangle. Color LCD only. +--- @since 2.4.0 +---@param x1 integer +---@param y1 integer +---@param x2 integer +---@param y2 integer +---@param xmin integer Clip region left +---@param xmax integer Clip region right +---@param ymin integer Clip region top +---@param ymax integer Clip region bottom +---@param pattern integer FORCE, ERASE, or DOTTED +---@param flags? integer Drawing flags +function lcd.drawLineWithClipping(x1, y1, x2, y2, xmin, xmax, ymin, ymax, pattern, flags) end + +--- Draw a HUD-style horizon rectangle with pitch and roll perspective. Color LCD only. +--- @since 2.4.0 +---@param pitch number Pitch angle in degrees +---@param roll number Roll angle in degrees +---@param xmin integer +---@param xmax integer +---@param ymin integer +---@param ymax integer +---@param flags? integer Drawing flags +function lcd.drawHudRectangle(pitch, roll, xmin, xmax, ymin, ymax, flags) end + +--- Exit full screen widget mode. Color LCD only. +--- @since 2.5.0 +function lcd.exitFullScreen() end + +--- Get the X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastPos() end + +--- Get the right edge X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastRightPos() end + +--- Get the left edge X position after the last lcd.drawText(). B&W LCD only. +---@return integer x +function lcd.getLastLeftPos() end + +-- ========================================================================== +-- Display helper functions +-- ========================================================================== + +--- Convert a value (0-15) to a grey color flag. B&W LCD only (not color LCD). +---@param value integer Grey level (0-15) +---@return integer color +function GREY(value) end + +-- ========================================================================== +-- Display flag constants +-- ========================================================================== + +---@type integer +FULLSCALE = 0 +---@type integer +STDSIZE = 0 +---@type integer +XXLSIZE = 0 +---@type integer +DBLSIZE = 0 +---@type integer +MIDSIZE = 0 +---@type integer +SMLSIZE = 0 +---@type integer +TINSIZE = 0 +---@type integer +BOLD = 0 +---@type integer +BLINK = 0 +---@type integer +INVERS = 0 +---@type integer +VCENTER = 0 +---@type integer +VTOP = 0 +---@type integer +VBOTTOM = 0 +---@type integer +RIGHT = 0 +---@type integer +LEFT = 0 +---@type integer +CENTER = 0 +---@type integer +PREC1 = 0 +---@type integer +PREC2 = 0 +---@type integer +SHADOWED = 0 +---@type integer +FIXEDWIDTH = 0 + +-- ========================================================================== +-- Color constants (color LCD only) +-- ========================================================================== + +---@type integer +COLOR_THEME_PRIMARY1 = 0 +---@type integer +COLOR_THEME_PRIMARY2 = 0 +---@type integer +COLOR_THEME_PRIMARY3 = 0 +---@type integer +COLOR_THEME_SECONDARY1 = 0 +---@type integer +COLOR_THEME_SECONDARY2 = 0 +---@type integer +COLOR_THEME_SECONDARY3 = 0 +---@type integer +COLOR_THEME_FOCUS = 0 +---@type integer +COLOR_THEME_EDIT = 0 +---@type integer +COLOR_THEME_ACTIVE = 0 +---@type integer +COLOR_THEME_WARNING = 0 +---@type integer +COLOR_THEME_DISABLED = 0 +---@type integer +CUSTOM_COLOR = 0 +---@type integer +BLACK = 0 +---@type integer +WHITE = 0 +---@type integer +LIGHTWHITE = 0 +---@type integer +LIGHTGREY = 0 +---@type integer +DARKGREY = 0 +---@type integer +RED = 0 +---@type integer +DARKRED = 0 +---@type integer +LIGHTRED = 0 +---@type integer +GREEN = 0 +---@type integer +DARKGREEN = 0 +---@type integer +BRIGHTGREEN = 0 +---@type integer +BLUE = 0 +---@type integer +DARKBLUE = 0 +---@type integer +CYAN = 0 +---@type integer +YELLOW = 0 +---@type integer +LIGHTBROWN = 0 +---@type integer +DARKBROWN = 0 +---@type integer +ORANGE = 0 +---@type integer +MAGENTA = 0 + +-- ========================================================================== +-- Drawing pattern constants +-- ========================================================================== + +---@type integer +SOLID = 0 +---@type integer +DOTTED = 0 +---@type integer +FILL_WHITE = 0 +---@type integer +GREY_DEFAULT = 0 +---@type integer +FORCE = 0 +---@type integer +ERASE = 0 +---@type integer +ROUND = 0 + +-- ========================================================================== +-- Screen dimension constants +-- ========================================================================== + +---@type integer +LCD_W = 0 +---@type integer +LCD_H = 0 +---@type integer +MENU_HEADER_HEIGHT = 0 + +-- ========================================================================== +-- Special character constants (light userdata) +-- ========================================================================== + +---@type any +CHAR_RIGHT = nil +---@type any +CHAR_LEFT = nil +---@type any +CHAR_UP = nil +---@type any +CHAR_DOWN = nil +---@type any +CHAR_DELTA = nil +---@type any +CHAR_STICK = nil +---@type any +CHAR_POT = nil +---@type any +CHAR_SLIDER = nil +---@type any +CHAR_SWITCH = nil +---@type any +CHAR_TRIM = nil +---@type any +CHAR_INPUT = nil +---@type any +CHAR_FUNCTION = nil +---@type any +CHAR_CYC = nil +---@type any +CHAR_TRAINER = nil +---@type any +CHAR_CHANNEL = nil +---@type any +CHAR_TELEMETRY = nil +---@type any +CHAR_LUA = nil +---@type any +CHAR_LS = nil +---@type any +CHAR_CURVE = nil diff --git a/edgetx-lua-stdlib/lua-runtime.d.lua b/edgetx-lua-stdlib/lua-runtime.d.lua new file mode 100644 index 0000000..cef07ac --- /dev/null +++ b/edgetx-lua-stdlib/lua-runtime.d.lua @@ -0,0 +1,94 @@ +---@meta + +-- EdgeTX Lua runtime overrides +-- Only libraries that differ from standard Lua 5.3 are redefined here. +-- string, math (core), bit32, table, and base globals use LuaLS builtins. + +-- ========================================================================== +-- io library (5 functions only, non-standard signatures) +-- EdgeTX uses FatFs; all functions take a file handle as the first argument. +-- No file methods (:read, :write, etc.), no io.lines, io.input, io.output. +-- ========================================================================== + +---@class file* + +io = {} + +--- Open a file on the SD card. Returns file handle on success, or nil + error +--- message on failure. +---@param filename string Full path on SD card +---@param mode? "r"|"rb"|"w"|"wb"|"a"|"ab" Default "r". Only the first character matters; 'b' is accepted but ignored. +---@return file*|nil handle +---@return string|nil errmsg +function io.open(filename, mode) end + +--- Read up to `size` bytes from the file. Returns an empty string at end of file. +---@param file file* +---@param size integer Number of bytes to read +---@return string data Empty string on EOF +function io.read(file, size) end + +--- Write values to the file. Accepts strings and numbers. +---@param file file* +---@param ... string|number +---@return file* file The file handle on success +function io.write(file, ...) end + +--- Close the file handle. +---@param file file* +function io.close(file) end + +--- Seek to an absolute byte offset in the file. +---@param file file* +---@param offset integer Absolute byte offset (unsigned) +---@return integer result FRESULT (0 = OK) +function io.seek(file, offset) end + +-- ========================================================================== +-- math compat functions (LUA_COMPAT_5_2 / LUA_COMPAT_MATHLIB) +-- These are deprecated in standard Lua 5.3 but available in EdgeTX. +-- ========================================================================== + +---@deprecated Use `math.atan(y, x)` instead +---@param y number +---@param x? number +---@return number +function math.atan2(y, x) end + +---@deprecated +---@param x number +---@return number +function math.cosh(x) end + +---@deprecated +---@param x number +---@return number +function math.sinh(x) end + +---@deprecated +---@param x number +---@return number +function math.tanh(x) end + +---@deprecated Use `x ^ y` instead +---@param x number +---@param y number +---@return number +function math.pow(x, y) end + +---@deprecated +---@param x number +---@return number m +---@return integer e +function math.frexp(x) end + +---@deprecated +---@param m number +---@param e integer +---@return number +function math.ldexp(m, e) end + +---@deprecated Use `math.log(x, 10)` instead +---@param x number +---@return number +function math.log10(x) end diff --git a/edgetx-lua-stdlib/lvgl.d.lua b/edgetx-lua-stdlib/lvgl.d.lua new file mode 100644 index 0000000..e9f67b3 --- /dev/null +++ b/edgetx-lua-stdlib/lvgl.d.lua @@ -0,0 +1,848 @@ +---@meta + +-- EdgeTX LVGL module (color LCD only) +-- Source: edgetx/radio/src/lua/api_colorlcd_lvgl.cpp + +---@class LvglObject +local LvglObject = {} + +---@param settings LvglLabelSettings +---@return LvglObject +function LvglObject:label(settings) end + +---@param settings LvglRectangleSettings +---@return LvglObject +function LvglObject:rectangle(settings) end + +---@param settings LvglBoxSettings +---@return LvglObject +function LvglObject:box(settings) end + +---@param settings LvglButtonSettings +---@return LvglObject +function LvglObject:button(settings) end + +---@param settings LvglMomentaryButtonSettings +---@return LvglObject +function LvglObject:momentaryButton(settings) end + +---@param settings LvglToggleSettings +---@return LvglObject +function LvglObject:toggle(settings) end + +---@param settings LvglTextEditSettings +---@return LvglObject +function LvglObject:textEdit(settings) end + +---@param settings LvglNumberEditSettings +---@return LvglObject +function LvglObject:numberEdit(settings) end + +---@param settings LvglChoiceSettings +---@return LvglObject +function LvglObject:choice(settings) end + +---@param settings LvglSliderSettings +---@return LvglObject +function LvglObject:slider(settings) end + +---@param settings LvglSettingSettings +---@return LvglObject +function LvglObject:setting(settings) end + +---@param settings LvglHlineSettings +---@return LvglObject +function LvglObject:hline(settings) end + +---@param settings LvglVlineSettings +---@return LvglObject +function LvglObject:vline(settings) end + +---@param settings LvglLineSettings +---@return LvglObject +function LvglObject:line(settings) end + +---@param settings LvglTriangleSettings +---@return LvglObject +function LvglObject:triangle(settings) end + +---@param settings LvglCircleSettings +---@return LvglObject +function LvglObject:circle(settings) end + +---@param settings LvglArcSettings +---@return LvglObject +function LvglObject:arc(settings) end + +---@param settings LvglImageSettings +---@return LvglObject +function LvglObject:image(settings) end + +---@param settings LvglQrcodeSettings +---@return LvglObject +function LvglObject:qrcode(settings) end + +---@param settings LvglSourceSettings +---@return LvglObject +function LvglObject:source(settings) end + +---@param settings LvglSwitchSettings +---@return LvglObject +function LvglObject:switch(settings) end + +function LvglObject:show() end +function LvglObject:hide() end +function LvglObject:enable() end +function LvglObject:disable() end +function LvglObject:close() end +function LvglObject:clear() end + +---@param tree table[] +---@return table +function LvglObject:build(tree) end + +---@param settings table +function LvglObject:set(settings) end + +---@return integer x +---@return integer y +function LvglObject:getScrollPos() end + +-- ========================================================================== +-- Common settings (inherited by all widgets via LvglWidgetObjectBase) +-- ========================================================================== + +---@class LvglCommonSettings +---@field x? integer X position (or PERCENT_SIZE+N for percentage) +---@field y? integer Y position +---@field w? integer Width (0 = auto size to fit content, or PERCENT_SIZE+N) +---@field h? integer Height (0 = auto size to fit content, or PERCENT_SIZE+N) +---@field color? integer|fun():integer Primary color +---@field opacity? integer|fun():integer 0-255 +---@field visible? fun():boolean Dynamic visibility +---@field size? fun(w: integer, h: integer): integer, integer Dynamic size callback +---@field pos? fun(): integer, integer Dynamic position callback + +-- ========================================================================== +-- Page +-- ========================================================================== + +--- Full-screen page layout with header bar, title, and optional navigation. +---@class LvglPageSettings +---@field title? string|fun():string Header title text +---@field subtitle? string|fun():string Header subtitle text +---@field icon? string Path to 30x30 grayscale mask image +---@field back? fun() Called on back button / RTN key +---@field menu? fun() Called on menu button +---@field prevButton? {press: fun(), active?: fun():boolean} +---@field nextButton? {press: fun(), active?: fun():boolean} +---@field backButton? boolean Show exit/back button in header right +---@field flexFlow? integer FLOW_ROW or FLOW_COLUMN +---@field flexPad? integer Padding between flex items +---@field scrollBar? boolean +---@field scrollDir? integer SCROLL_OFF, SCROLL_HOR, SCROLL_VER, SCROLL_ALL +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field align? integer Flex layout alignment (LEFT, CENTER, RIGHT, VTOP, VCENTER, VBOTTOM) +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean + +-- ========================================================================== +-- Dialog +-- ========================================================================== + +--- Modal dialog with a title bar and close button. +---@class LvglDialogSettings : LvglCommonSettings +---@field title? string|fun():string +---@field flexFlow? integer +---@field flexPad? integer +---@field close? fun() Called when dialog is closed +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean + +-- ========================================================================== +-- Confirm / Message popups +-- ========================================================================== + +--- Yes/No confirmation dialog. +---@class LvglConfirmSettings +---@field title? string|fun():string +---@field message? string +---@field confirm? fun() Called when Yes is tapped +---@field cancel? fun() Called when No is tapped + +--- Information message dialog. +---@class LvglMessageSettings +---@field title? string|fun():string +---@field message? string +---@field details? string Additional detail text + +-- ========================================================================== +-- Menu +-- ========================================================================== + +--- Popup menu for selecting from a list of options. +---@class LvglMenuSettings +---@field title? string|fun():string +---@field values? string[] List of menu item strings +---@field get? fun():integer Current selection index (1-based) +---@field set? fun(index: integer) Selection changed + +-- ========================================================================== +-- Label +-- ========================================================================== + +--- Display a text label. +---@class LvglLabelSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer Font flag (BOLD, SMLSIZE, MIDSIZE, DBLSIZE, etc.) +---@field align? integer|fun():integer Text alignment (LEFT, CENTER, RIGHT, VCENTER, etc.) +---@field children? table[] + +-- ========================================================================== +-- Rectangle +-- ========================================================================== + +--- Display a rectangle (outline or filled). +---@class LvglRectangleSettings : LvglCommonSettings +---@field thickness? integer Border thickness (0 = invisible border) +---@field filled? boolean|fun():boolean +---@field rounded? integer Corner radius (must be >= thickness if > 0) +---@field flexFlow? integer +---@field flexPad? integer +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field align? integer|fun():integer +---@field scrollBar? boolean +---@field scrollDir? integer +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Box (layout container) +-- ========================================================================== + +--- Invisible container for managing child layout. +---@class LvglBoxSettings : LvglCommonSettings +---@field flexFlow? integer FLOW_ROW or FLOW_COLUMN +---@field flexPad? integer Padding between flex items +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field align? integer|fun():integer +---@field scrollBar? boolean +---@field scrollDir? integer +---@field scrolled? fun(x: integer, y: integer) +---@field scrollTo? fun(): integer, integer +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Button +-- ========================================================================== + +--- Text button using the EdgeTX style. +---@class LvglButtonSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer +---@field cornerRadius? integer +---@field textColor? integer|fun():integer +---@field press? fun() Called when button is tapped +---@field longpress? fun() Called on long press +---@field checked? boolean Initial checked state +---@field active? fun():boolean + +-- ========================================================================== +-- Momentary Button +-- ========================================================================== + +--- Momentary button that fires on press and release. +---@class LvglMomentaryButtonSettings : LvglCommonSettings +---@field text? string|fun():string +---@field font? integer|fun():integer +---@field cornerRadius? integer +---@field textColor? integer|fun():integer +---@field press? fun() Called on press +---@field release? fun() Called on release +---@field active? fun():boolean + +-- ========================================================================== +-- Toggle +-- ========================================================================== + +--- Toggle switch using the EdgeTX style. +---@class LvglToggleSettings : LvglCommonSettings +---@field get? fun():integer Returns 0 or 1 +---@field set? fun(value: integer) Receives 0 or 1 +---@field active? fun():boolean + +-- ========================================================================== +-- Text Edit +-- ========================================================================== + +--- Text input field. +---@class LvglTextEditSettings : LvglCommonSettings +---@field value? string Initial text value +---@field length? integer Max text length (1-128, default 32) +---@field set? fun(value: string) Called when text is changed +---@field active? fun():boolean + +-- ========================================================================== +-- Number Edit +-- ========================================================================== + +--- Numeric input field with +/- adjustment. +---@class LvglNumberEditSettings : LvglCommonSettings +---@field min? integer Default -1024 +---@field max? integer Default 1024 +---@field get? fun():integer +---@field set? fun(value: integer) Called on user interaction +---@field edited? fun(value: integer) Called when editing is committed +---@field display? fun(value: integer):string Custom display formatting +---@field active? fun():boolean + +-- ========================================================================== +-- Choice (dropdown) +-- ========================================================================== + +--- Button that opens a popup menu for selecting from a list. +---@class LvglChoiceSettings : LvglCommonSettings +---@field title? string|fun():string Popup title +---@field values? string[] List of option strings +---@field get? fun():integer Current selection index (1-based) +---@field set? fun(index: integer) Selection changed (receives 1..n) +---@field filter? fun(index: integer):boolean Filter callback for available options +---@field popupWidth? integer Width of the choice popup +---@field active? fun():boolean + +-- ========================================================================== +-- Slider +-- ========================================================================== + +--- Horizontal slider. +---@class LvglSliderSettings : LvglCommonSettings +---@field min? integer Default 0 +---@field max? integer Default 100 +---@field get? fun():integer +---@field set? fun(value: integer) Called on user interaction +---@field active? fun():boolean + +-- ========================================================================== +-- Setting (label + control container) +-- ========================================================================== + +--- A labeled row containing a title and child controls. +---@class LvglSettingSettings : LvglCommonSettings +---@field title? string|fun():string +---@field flexFlow? integer +---@field flexPad? integer +---@field borderPad? integer|{left?: integer, right?: integer, top?: integer, bottom?: integer} +---@field active? fun():boolean +---@field children? table[] + +-- ========================================================================== +-- Source selector +-- ========================================================================== + +--- Source picker control. +---@class LvglSourceSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field filter? integer Bitmask of SRC_* constants +---@field active? fun():boolean + +-- ========================================================================== +-- Switch selector +-- ========================================================================== + +--- Switch picker control. +---@class LvglSwitchSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field filter? integer Bitmask of SW_* constants +---@field active? fun():boolean + +-- ========================================================================== +-- File picker +-- ========================================================================== + +--- File selection control. +---@class LvglFileSettings : LvglCommonSettings +---@field title? string|fun():string +---@field folder? string Folder path +---@field extension? string File extension filter +---@field maxLen? integer Maximum filename length +---@field hideExtension? boolean Hide extension in display +---@field get? fun():string +---@field set? fun(value: string) +---@field active? fun():boolean + +-- ========================================================================== +-- Font picker +-- ========================================================================== + +--- Font selection control. +---@class LvglFontSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Align picker +-- ========================================================================== + +--- Alignment selection control. +---@class LvglAlignSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Color picker +-- ========================================================================== + +--- Color selection control. +---@class LvglColorSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Timer picker +-- ========================================================================== + +--- Timer selection control. +---@class LvglTimerSettings : LvglCommonSettings +---@field get? fun():integer +---@field set? fun(value: integer) +---@field active? fun():boolean + +-- ========================================================================== +-- Geometry primitives +-- ========================================================================== + +--- Horizontal line. +---@class LvglHlineSettings : LvglCommonSettings +---@field thickness? integer +---@field rounded? boolean Round end caps +---@field dashGap? integer Gap size for dashed lines +---@field dashWidth? integer Dash size for dashed lines + +--- Vertical line. +---@class LvglVlineSettings : LvglCommonSettings +---@field thickness? integer +---@field rounded? boolean Round end caps +---@field dashGap? integer +---@field dashWidth? integer + +--- One or more connected line segments. +---@class LvglLineSettings : LvglCommonSettings +---@field pts? table|fun():table Array of {x, y} points (at least 2) +---@field thickness? integer +---@field rounded? boolean Round end caps + +--- Filled triangle defined by 3 points. +---@class LvglTriangleSettings : LvglCommonSettings +---@field pts? table|fun():table Array of 3 {x, y} points + +--- Circle (outline or filled). +---@class LvglCircleSettings : LvglCommonSettings +---@field radius? integer|fun():integer +---@field filled? boolean|fun():boolean +---@field thickness? integer + +--- Arc segment with optional background arc. +---@class LvglArcSettings : LvglCommonSettings +---@field radius? integer|fun():integer +---@field thickness? integer +---@field rounded? boolean Round arc ends +---@field startAngle? integer|fun():integer Degrees (0=right/3 o'clock, clockwise) +---@field endAngle? integer|fun():integer +---@field bgColor? integer|fun():integer Background arc color +---@field bgOpacity? integer|fun():integer Background arc opacity (0-255) +---@field bgStartAngle? integer|fun():integer +---@field bgEndAngle? integer|fun():integer + +--- Display an image from the SD card. +---@class LvglImageSettings : LvglCommonSettings +---@field file? string|fun():string Full path to image file +---@field fill? boolean Scale to fill parent frame (may crop) +---@field active? fun():boolean + +--- Display a QR code. +---@class LvglQrcodeSettings : LvglCommonSettings +---@field data? string URL or content to encode +---@field bgColor? integer Background (light) color + +-- ========================================================================== +-- lvgl module +-- ========================================================================== + +---@class lvgl +lvgl = {} + +--- Remove all LVGL objects, or only children of a specific object. +---@param obj? LvglObject +function lvgl.clear(obj) end + +--- Build a widget tree from a declarative table. +---@param tree table[] +---@return table +function lvgl.build(tree) end + +--- Check if the current script is running in app mode (One-Time script). +---@return boolean +function lvgl.isAppMode() end + +--- Check if the widget is running in full-screen mode. +---@return boolean +function lvgl.isFullScreen() end + +--- Exit full-screen widget mode. +function lvgl.exitFullScreen() end + +--- Get the current widget context table. +---@return table context +function lvgl.getContext() end + +--- Update properties of an existing LVGL object. +---@param obj LvglObject +---@param settings table +function lvgl.set(obj, settings) end + +--- Show an LVGL object. +---@param obj LvglObject +function lvgl.show(obj) end + +--- Hide an LVGL object. +---@param obj LvglObject +function lvgl.hide(obj) end + +--- Enable an LVGL object (restore interactivity). +---@param obj LvglObject +function lvgl.enable(obj) end + +--- Disable an LVGL object (grey out, prevent interaction). +---@param obj LvglObject +function lvgl.disable(obj) end + +--- Close and destroy an LVGL object. +---@param obj LvglObject +function lvgl.close(obj) end + +--- Get the scroll position of an LVGL object. +---@param obj LvglObject +---@return integer x +---@return integer y +function lvgl.getScrollPos(obj) end + +-- Widget creation functions + +---@param settings LvglPageSettings +---@return LvglObject +function lvgl.page(settings) end + +---@param parent_or_settings LvglObject|LvglDialogSettings +---@param settings? LvglDialogSettings +---@return LvglObject +function lvgl.dialog(parent_or_settings, settings) end + +---@param settings LvglConfirmSettings +---@return LvglObject +function lvgl.confirm(settings) end + +---@param settings LvglMessageSettings +---@return LvglObject +function lvgl.message(settings) end + +---@param settings LvglMenuSettings +---@return LvglObject +function lvgl.menu(settings) end + +---@param parent_or_settings LvglObject|LvglLabelSettings +---@param settings? LvglLabelSettings +---@return LvglObject +function lvgl.label(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglRectangleSettings +---@param settings? LvglRectangleSettings +---@return LvglObject +function lvgl.rectangle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglBoxSettings +---@param settings? LvglBoxSettings +---@return LvglObject +function lvgl.box(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglButtonSettings +---@param settings? LvglButtonSettings +---@return LvglObject +function lvgl.button(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglMomentaryButtonSettings +---@param settings? LvglMomentaryButtonSettings +---@return LvglObject +function lvgl.momentaryButton(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglToggleSettings +---@param settings? LvglToggleSettings +---@return LvglObject +function lvgl.toggle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTextEditSettings +---@param settings? LvglTextEditSettings +---@return LvglObject +function lvgl.textEdit(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglNumberEditSettings +---@param settings? LvglNumberEditSettings +---@return LvglObject +function lvgl.numberEdit(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglChoiceSettings +---@param settings? LvglChoiceSettings +---@return LvglObject +function lvgl.choice(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSliderSettings +---@param settings? LvglSliderSettings +---@return LvglObject +function lvgl.slider(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSliderSettings +---@param settings? LvglSliderSettings +---@return LvglObject +function lvgl.verticalSlider(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSettingSettings +---@param settings? LvglSettingSettings +---@return LvglObject +function lvgl.setting(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSourceSettings +---@param settings? LvglSourceSettings +---@return LvglObject +function lvgl.source(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglSwitchSettings +---@param settings? LvglSwitchSettings +---@return LvglObject +function lvgl.switch(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglFileSettings +---@param settings? LvglFileSettings +---@return LvglObject +function lvgl.file(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglFontSettings +---@param settings? LvglFontSettings +---@return LvglObject +function lvgl.font(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglAlignSettings +---@param settings? LvglAlignSettings +---@return LvglObject +function lvgl.align(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglColorSettings +---@param settings? LvglColorSettings +---@return LvglObject +function lvgl.color(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTimerSettings +---@param settings? LvglTimerSettings +---@return LvglObject +function lvgl.timer(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglHlineSettings +---@param settings? LvglHlineSettings +---@return LvglObject +function lvgl.hline(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglVlineSettings +---@param settings? LvglVlineSettings +---@return LvglObject +function lvgl.vline(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglLineSettings +---@param settings? LvglLineSettings +---@return LvglObject +function lvgl.line(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglTriangleSettings +---@param settings? LvglTriangleSettings +---@return LvglObject +function lvgl.triangle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglCircleSettings +---@param settings? LvglCircleSettings +---@return LvglObject +function lvgl.circle(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglArcSettings +---@param settings? LvglArcSettings +---@return LvglObject +function lvgl.arc(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglImageSettings +---@param settings? LvglImageSettings +---@return LvglObject +function lvgl.image(parent_or_settings, settings) end + +---@param parent_or_settings LvglObject|LvglQrcodeSettings +---@param settings? LvglQrcodeSettings +---@return LvglObject +function lvgl.qrcode(parent_or_settings, settings) end + +-- ========================================================================== +-- LVGL constants +-- ========================================================================== + +-- Layout flow +---@type integer +lvgl.FLOW_ROW = 0 +---@type integer +lvgl.FLOW_COLUMN = 0 + +-- Padding presets +---@type integer +lvgl.PAD_TINY = 0 +---@type integer +lvgl.PAD_SMALL = 0 +---@type integer +lvgl.PAD_MEDIUM = 0 +---@type integer +lvgl.PAD_LARGE = 0 +---@type integer +lvgl.PAD_OUTLINE = 0 +---@type integer +lvgl.PAD_BORDER = 0 + +-- Source filter bitmask constants +---@type integer +lvgl.SRC_ALL = 0 +---@type integer +lvgl.SRC_INPUT = 0 +---@type integer +lvgl.SRC_LUA = 0 +---@type integer +lvgl.SRC_STICK = 0 +---@type integer +lvgl.SRC_POT = 0 +---@type integer +lvgl.SRC_OTHER = 0 +---@type integer +lvgl.SRC_HELI = 0 +---@type integer +lvgl.SRC_TRIM = 0 +---@type integer +lvgl.SRC_SWITCH = 0 +---@type integer +lvgl.SRC_LOGICAL_SWITCH = 0 +---@type integer +lvgl.SRC_TRAINER = 0 +---@type integer +lvgl.SRC_CHANNEL = 0 +---@type integer +lvgl.SRC_GVAR = 0 +---@type integer +lvgl.SRC_TELEM = 0 +---@type integer +lvgl.SRC_CLEAR = 0 +---@type integer +lvgl.SRC_INVERT = 0 + +-- Switch filter bitmask constants +---@type integer +lvgl.SW_ALL = 0 +---@type integer +lvgl.SW_SWITCH = 0 +---@type integer +lvgl.SW_TRIM = 0 +---@type integer +lvgl.SW_LOGICAL_SWITCH = 0 +---@type integer +lvgl.SW_TELEM = 0 +---@type integer +lvgl.SW_OTHER = 0 +---@type integer +lvgl.SW_CLEAR = 0 + +-- Scroll direction constants +---@type integer +lvgl.SCROLL_OFF = 0 +---@type integer +lvgl.SCROLL_HOR = 0 +---@type integer +lvgl.SCROLL_VER = 0 +---@type integer +lvgl.SCROLL_ALL = 0 + +-- Layout helper constants +---@type integer +lvgl.PERCENT_SIZE = 0 +---@type integer +lvgl.PAGE_BODY_HEIGHT = 0 +---@type integer +lvgl.UI_ELEMENT_HEIGHT = 0 +---@type number +lvgl.LCD_SCALE = 0.0 + +-- Widget type constants (for use in build() tables) +---@type integer +lvgl.LABEL = 0 +---@type integer +lvgl.RECTANGLE = 0 +---@type integer +lvgl.CIRCLE = 0 +---@type integer +lvgl.ARC = 0 +---@type integer +lvgl.HLINE = 0 +---@type integer +lvgl.VLINE = 0 +---@type integer +lvgl.LINE = 0 +---@type integer +lvgl.TRIANGLE = 0 +---@type integer +lvgl.IMAGE = 0 +---@type integer +lvgl.QRCODE = 0 +---@type integer +lvgl.BOX = 0 +---@type integer +lvgl.BUTTON = 0 +---@type integer +lvgl.MOMENTARY_BUTTON = 0 +---@type integer +lvgl.TOGGLE = 0 +---@type integer +lvgl.TEXT_EDIT = 0 +---@type integer +lvgl.NUMBER_EDIT = 0 +---@type integer Typo in C++ source, kept for compatibility +lvgl.CHOIDE = 0 +---@type integer +lvgl.CHOICE = 0 +---@type integer +lvgl.SLIDER = 0 +---@type integer +lvgl.VERTICAL_SLIDER = 0 +---@type integer +lvgl.PAGE = 0 +---@type integer +lvgl.FONT = 0 +---@type integer +lvgl.ALIGN = 0 +---@type integer +lvgl.COLOR = 0 +---@type integer +lvgl.TIMER = 0 +---@type integer +lvgl.SWITCH = 0 +---@type integer +lvgl.SOURCE = 0 +---@type integer +lvgl.FILE = 0 +---@type integer +lvgl.SETTING = 0 diff --git a/edgetx-lua-stdlib/model.d.lua b/edgetx-lua-stdlib/model.d.lua new file mode 100644 index 0000000..e78ec8b --- /dev/null +++ b/edgetx-lua-stdlib/model.d.lua @@ -0,0 +1,352 @@ +---@meta + +-- EdgeTX model module +-- Source: edgetx/radio/src/lua/api_model.cpp + +---@class model +model = {} + +--- Get current model information. +--- @since 2.0.6 +---@return {name: string, extendedLimits: boolean, jitterFilter: integer, bitmap: string, labels: string, filename: string} +function model.getInfo() end + +--- Set current model information. Missing fields remain unchanged. +--- @since 2.0.6 +---@param info {name?: string, extendedLimits?: boolean, jitterFilter?: integer, bitmap?: string} +function model.setInfo(info) end + +--- Get RF module parameters. Returns nil if index is invalid. +--- @since 2.2.0 +---@param index integer Module slot (0=internal, 1=external) +---@return {subType: integer, modelId: integer, firstChannel: integer, channelsCount: integer, Type: integer, protocol?: integer, subProtocol?: integer, channelsOrder?: integer}|nil module +function model.getModule(index) end + +--- Set RF module parameters. Missing fields remain unchanged. +--- @since 2.2.0 +---@param index integer Module slot (0=internal, 1=external) +---@param config {Type?: integer, subType?: integer, modelId?: integer, firstChannel?: integer, channelsCount?: integer, protocol?: integer, subProtocol?: integer} +function model.setModule(index, config) end + +--- Get model timer parameters. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +---@return {mode: integer, start: integer, value: integer, countdownBeep: integer, minuteBeep: boolean, persistent: integer, name: string, showElapsed: boolean, switch: integer, countdownStart: integer, extraHaptic: integer}|nil timer +function model.getTimer(index) end + +--- Set model timer parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +---@param config {mode?: integer, start?: integer, value?: integer, countdownBeep?: integer, minuteBeep?: boolean, persistent?: integer, name?: string, showElapsed?: boolean, switch?: integer, countdownStart?: integer, extraHaptic?: integer} +function model.setTimer(index, config) end + +--- Reset model timer to its startup value. +--- @since 2.0.0 +---@param index integer Timer index (0-based) +function model.resetTimer(index) end + +--- Clear all flight modes. +function model.deleteFlightModes() end + +--- Get flight mode data. +--- @since 2.1.7 +---@param index integer Flight mode index (0-based) +---@return {name: string, switch: integer, fadeIn: integer, fadeOut: integer, trimsValues: table, trimsModes: table} flightMode +function model.getFlightMode(index) end + +--- Set flight mode parameters. Missing fields remain unchanged. +--- @since 2.1.7 +---@param index integer Flight mode index (0-based) +---@param config {name?: string, switch?: integer, fadeIn?: integer, fadeOut?: integer, trimsValues?: table, trimsModes?: table} +---@return integer result 0=success, 2=invalid index +function model.setFlightMode(index, config) end + +--- Return the number of lines for the given input. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@return integer count +function model.getInputsCount(input) end + +--- Get input line configuration. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number within input (0-based) +---@return {name: string, inputName: string, source: integer, scale: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, trimSource: integer, side: integer, flightModes: integer} inputConfig +function model.getInput(input, line) end + +--- Insert an input line at the specified position. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number to insert at (0-based) +---@param config {name?: string, inputName?: string, source?: integer, scale?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, trimSource?: integer, side?: integer, flightModes?: integer} +function model.insertInput(input, line, config) end + +--- Delete a line from the specified input. +--- @since 2.0.0 +---@param input integer Input index (0-based) +---@param line integer Line number (0-based) +function model.deleteInput(input, line) end + +--- Delete all input lines. +function model.deleteInputs() end + +--- Set all inputs to defaults (one stick per input). +function model.defaultInputs() end + +--- Get the number of mixer lines for the given channel. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@return integer count +function model.getMixesCount(channel) end + +--- Get mixer line configuration. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number within channel (0-based) +---@return {name: string, source: integer, weight: integer, offset: integer, switch: integer, curveType: integer, curveValue: integer, multiplex: integer, flightModes: integer, carryTrim: boolean, mixWarn: integer, delayPrec: integer, delayUp: integer, delayDown: integer, speedPrec: integer, speedUp: integer, speedDown: integer} mix +function model.getMix(channel, line) end + +--- Insert a mixer line at the specified position. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number to insert at (0-based) +---@param config {name?: string, source?: integer, weight?: integer, offset?: integer, switch?: integer, curveType?: integer, curveValue?: integer, multiplex?: integer, flightModes?: integer, carryTrim?: boolean, mixWarn?: integer, delayPrec?: integer, delayUp?: integer, delayDown?: integer, speedPrec?: integer, speedUp?: integer, speedDown?: integer} +function model.insertMix(channel, line, config) end + +--- Delete a mixer line from the specified channel. +--- @since 2.0.0 +---@param channel integer Channel index (0-based) +---@param line integer Line number (0-based) +function model.deleteMix(channel, line) end + +--- Remove all mixer lines. +function model.deleteMixes() end + +--- Get logical switch parameters. +--- @since 2.0.0 +---@param index integer Logical switch index (0-based) +---@return {func: integer, v1: integer, v2: integer, v3: integer, ["and"]: integer, delay: integer, duration: integer, state: boolean, persistent: boolean} logicalSwitch +function model.getLogicalSwitch(index) end + +--- Set logical switch parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Logical switch index (0-based) +---@param config {func?: integer, v1?: integer, v2?: integer, v3?: integer, ["and"]?: integer, delay?: integer, duration?: integer, state?: boolean, persistent?: boolean} +function model.setLogicalSwitch(index, config) end + +--- Get custom (special) function parameters. +--- @since 2.0.0 +---@param index integer Function index (0-based) +---@return {switch: integer, func: integer, name?: string, value?: integer, mode?: integer, param?: integer, active: integer, repetition: integer} customFunction +function model.getCustomFunction(index) end + +--- Set custom (special) function parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Function index (0-based) +---@param config {switch?: integer, func?: integer, name?: string, value?: integer, mode?: integer, param?: integer, active?: integer, repetition?: integer} +function model.setCustomFunction(index, config) end + +--- Get curve parameters. The `y` table contains point values (1-based). +--- The `x` table is only present for custom curve types. +--- @since 2.0.12 +---@param index integer Curve index (0-based) +---@return {name: string, type: integer, smooth: boolean, points: integer, y: table, x?: table} curve +function model.getCurve(index) end + +--- Set curve parameters. +--- @since 2.0.12 +---@param index integer Curve index (0-based) +---@param config {name?: string, type?: integer, smooth?: boolean, points?: integer, y?: table, x?: table} +---@return integer result 0=success, 1=wrong number of points, 2=invalid curve number +function model.setCurve(index, config) end + +--- Get output (servo) channel parameters. Values are in 0.1% units. +--- @since 2.0.0 +---@param index integer Output channel index (0-based) +---@return {name: string, min: integer, max: integer, offset: integer, ppmCenter: integer, symetrical: integer, revert: integer, curve?: integer} output +function model.getOutput(index) end + +--- Set output (servo) channel parameters. Missing fields remain unchanged. +--- @since 2.0.0 +---@param index integer Output channel index (0-based) +---@param config {name?: string, min?: integer, max?: integer, offset?: integer, ppmCenter?: integer, symetrical?: integer, revert?: integer, curve?: integer} +function model.setOutput(index, config) end + +--- Get the current value of a global variable. +--- @since 2.1.0 +---@param index integer GVar index (0-based) +---@param flightMode integer Flight mode index (0-based) +---@return integer value +function model.getGlobalVariable(index, flightMode) end + +--- Set the value of a global variable. +--- @since 2.1.0 +---@param index integer GVar index (0-based) +---@param flightMode integer Flight mode index (0-based) +---@param value integer +function model.setGlobalVariable(index, flightMode, value) end + +--- Get global variable details (name, range, unit, etc.). +---@param index integer GVar index (0-based) +---@return {name: string, min: integer, max: integer, prec: integer, unit: integer, popup: boolean}|nil details +function model.getGlobalVariableDetails(index) end + +--- Set global variable details. Missing fields remain unchanged. +---@param index integer GVar index (0-based) +---@param details {name?: string, min?: integer, max?: integer, prec?: integer, unit?: integer, popup?: boolean} +function model.setGlobalVariableDetails(index, details) end + +--- Get telemetry sensor parameters. The `id` and `instance` fields are +--- only present for custom sensors; `formula` is only present for calculated sensors. +--- @since 2.2.0 +---@param index integer Sensor index (0-based) +---@return {type: integer, name: string, unit: integer, prec: integer, id?: integer, instance?: integer, formula?: integer} sensor +function model.getSensor(index) end + +--- Reset telemetry sensor to default value. +---@param index integer Sensor index (0-based) +function model.resetSensor(index) end + +--- Get heli swash ring parameters. Only available on builds with HELI support. +---@return {type: integer, value: integer, collectiveSource: integer, aileronSource: integer, elevatorSource: integer, collectiveWeight: integer, aileronWeight: integer, elevatorWeight: integer} swashRing +function model.getSwashRing() end + +--- Set heli swash ring parameters. Missing fields remain unchanged. +--- Only available on builds with HELI support. +---@param config {type?: integer, value?: integer, collectiveSource?: integer, aileronSource?: integer, elevatorSource?: integer, collectiveWeight?: integer, aileronWeight?: integer, elevatorWeight?: integer} +function model.setSwashRing(config) end + +-- ========================================================================== +-- Global model-related functions +-- ========================================================================== + +--- Return trainer connection status. +---@return integer status +function getTrainerStatus() end + +-- ========================================================================== +-- Input / source type constants +-- ========================================================================== + +---@type integer +VALUE = 0 +---@type integer +SOURCE = 0 +---@type integer +REPLACE = 0 +---@type integer +MIXSRC_MIN = 0 +---@type integer +MIXSRC_MAX = 0 +---@type integer +MIXSRC_FIRST_INPUT = 0 +---@type integer +MIXSRC_CH1 = 0 +---@type integer +SWSRC_LAST = 0 +---@type integer +SWITCH_COUNT = 0 +---@type integer +MAX_SENSORS = 0 +---@type integer +MAX_OUTPUT_CHANNELS = 0 +---@type integer +LIMIT_EXT_PERCENT = 0 +---@type integer +LIMIT_STD_PERCENT = 0 + +-- ========================================================================== +-- Logical switch function constants +-- ========================================================================== + +---@type integer +LS_FUNC_NONE = 0 +---@type integer +LS_FUNC_VEQUAL = 0 +---@type integer +LS_FUNC_VALMOSTEQUAL = 0 +---@type integer +LS_FUNC_VPOS = 0 +---@type integer +LS_FUNC_VNEG = 0 +---@type integer +LS_FUNC_APOS = 0 +---@type integer +LS_FUNC_ANEG = 0 +---@type integer +LS_FUNC_AND = 0 +---@type integer +LS_FUNC_OR = 0 +---@type integer +LS_FUNC_XOR = 0 +---@type integer +LS_FUNC_EDGE = 0 +---@type integer +LS_FUNC_EQUAL = 0 +---@type integer +LS_FUNC_GREATER = 0 +---@type integer +LS_FUNC_LESS = 0 +---@type integer +LS_FUNC_DIFFEGREATER = 0 +---@type integer +LS_FUNC_ADIFFEGREATER = 0 +---@type integer +LS_FUNC_TIMER = 0 +---@type integer +LS_FUNC_STICKY = 0 + +-- ========================================================================== +-- Custom function constants +-- ========================================================================== + +---@type integer +FUNC_OVERRIDE_CHANNEL = 0 +---@type integer +FUNC_TRAINER = 0 +---@type integer +FUNC_INSTANT_TRIM = 0 +---@type integer +FUNC_RESET = 0 +---@type integer +FUNC_SET_TIMER = 0 +---@type integer +FUNC_ADJUST_GVAR = 0 +---@type integer +FUNC_VOLUME = 0 +---@type integer +FUNC_SET_FAILSAFE = 0 +---@type integer +FUNC_RANGECHECK = 0 +---@type integer +FUNC_BIND = 0 +---@type integer +FUNC_PLAY_SOUND = 0 +---@type integer +FUNC_PLAY_TRACK = 0 +---@type integer +FUNC_PLAY_VALUE = 0 +---@type integer +FUNC_PLAY_SCRIPT = 0 +---@type integer +FUNC_BACKGND_MUSIC = 0 +---@type integer +FUNC_BACKGND_MUSIC_PAUSE = 0 +---@type integer +FUNC_VARIO = 0 +---@type integer +FUNC_HAPTIC = 0 +---@type integer +FUNC_LOGS = 0 +---@type integer +FUNC_BACKLIGHT = 0 +---@type integer +FUNC_SCREENSHOT = 0 +---@type integer +FUNC_RACING_MODE = 0 +---@type integer +FUNC_PUSH_CUST_SWITCH = 0 +---@type integer +FUNC_SET_SCREEN = 0 +---@type integer +FUNC_DISABLE_TOUCH = 0 diff --git a/edgetx-lua-stdlib/runtime.d.lua b/edgetx-lua-stdlib/runtime.d.lua new file mode 100644 index 0000000..af45be6 --- /dev/null +++ b/edgetx-lua-stdlib/runtime.d.lua @@ -0,0 +1,22 @@ +---@meta + +-- EdgeTX Lua runtime introspection functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Load and compile a Lua script from the SD card. +--- Returns the compiled chunk or nil + error message. +--- @since 2.2.0 +---@param path? string Script file path +---@param mode? string Compilation mode +---@param env? table Custom environment table +---@return function|nil chunk +---@return string|nil errmsg +function loadScript(path, mode, env) end + +--- Return Lua memory usage as a percentage (0-100). +---@return integer percent +function getUsage() end + +--- Return available Lua memory in bytes. +---@return integer bytes +function getAvailableMemory() end diff --git a/edgetx-lua-stdlib/serial.d.lua b/edgetx-lua-stdlib/serial.d.lua new file mode 100644 index 0000000..96d4564 --- /dev/null +++ b/edgetx-lua-stdlib/serial.d.lua @@ -0,0 +1,28 @@ +---@meta + +-- EdgeTX serial port communication functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Set the serial port baud rate for Lua serial communication. +---@param baudrate integer Baud rate (e.g. 115200) +function setSerialBaudrate(baudrate) end + +--- Write a string to the serial port. +---@param data string Bytes to send +function serialWrite(data) end + +--- Read bytes from the serial port. Returns an empty string if no data available. +---@param num? integer Maximum number of bytes to read (0 = all available) +---@return string data +function serialRead(num) end + +--- Get external serial port power state. +---@param port_nr integer Port number +---@return boolean|nil power true/false for power state, nil if unavailable +function serialGetPower(port_nr) end + +--- Set external serial port power state. +---@param port_nr integer Port number +---@param power integer Power value (0=off, 1=on) +---@return boolean success +function serialSetPower(port_nr, power) end diff --git a/edgetx-lua-stdlib/telemetry.d.lua b/edgetx-lua-stdlib/telemetry.d.lua new file mode 100644 index 0000000..8e6050f --- /dev/null +++ b/edgetx-lua-stdlib/telemetry.d.lua @@ -0,0 +1,180 @@ +---@meta + +-- EdgeTX telemetry protocol functions and sensor unit constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Push telemetry data via ACCESS protocol (PXX2). When called without +--- parameters, returns the output buffer availability. +---@overload fun(): boolean +---@param module integer Module number (-1 for default) +---@param rxUid integer Receiver UID +---@param sensorId integer Physical sensor ID +---@param frameId integer Frame type +---@param dataId integer Data ID +---@param value integer Data value +---@return boolean success +function accessTelemetryPush(module, rxUid, sensorId, frameId, dataId, value) end + +--- Pop a received S.PORT telemetry frame. Returns nil if queue is empty. +--- @since 2.0.0 +---@return integer|nil physicalId +---@return integer|nil primId +---@return integer|nil dataId +---@return integer|nil value +function sportTelemetryPop() end + +--- Push an S.PORT telemetry frame. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.0.0 +---@overload fun(): boolean +---@param sensor integer Sensor physical ID +---@param frame integer Frame type +---@param dataId integer Data ID +---@param value integer Data value +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function sportTelemetryPush(sensor, frame, dataId, value) end + +--- Set a telemetry sensor value. Automatically creates the sensor if needed. +--- @since 2.2.0 +---@param id integer Sensor ID +---@param subId integer Sub-ID (0-7) +---@param instance integer Instance +---@param value integer Sensor value +---@param unit? integer Unit constant (UNIT_*, default 0) +---@param precision? integer Decimal precision (default 0) +---@param name? string Sensor name (up to 4 chars, auto-generated from ID if omitted) +---@return boolean success +function setTelemetryValue(id, subId, instance, value, unit, precision, name) end + +--- Pop a received Crossfire telemetry packet. Returns nil if queue is empty. +--- @since 2.2.0 +---@return integer|nil command +---@return integer[]|nil data Array of payload bytes +function crossfireTelemetryPop() end + +--- Push a Crossfire telemetry packet. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.2.0 +---@overload fun(): boolean +---@param command integer CRSF command byte +---@param data integer[] Array of payload bytes +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function crossfireTelemetryPush(command, data) end + +--- Pop a received Ghost telemetry packet. Returns nil if queue is empty. +--- @since 2.7.0 +---@return integer|nil type +---@return integer[]|nil data Array of payload bytes +function ghostTelemetryPop() end + +--- Push a Ghost telemetry packet. When called without parameters, +--- returns buffer availability. Returns nil if protocol is wrong. +--- @since 2.7.0 +---@overload fun(): boolean +---@param type integer Ghost frame type +---@param data integer[] Array of payload bytes (max 10) +---@return nil|boolean success nil if wrong protocol, boolean otherwise +function ghostTelemetryPush(type, data) end + +--- Read or write the Multi module shared buffer. When called with two +--- arguments, writes `value` at `index`. Always returns the current value. +---@param index integer Buffer index (0-based) +---@param value? integer Value to write (omit to just read; internal sentinel is 0x100) +---@return integer value Current buffer value at index +function multiBuffer(index, value) end + +--- Return RSSI value and alarm thresholds. +--- @since 2.0.0 +---@return integer rssi Current RSSI +---@return integer alarm_low Low alarm threshold +---@return integer alarm_crit Critical alarm threshold +function getRSSI() end + +--- Return RSSI Alarm Status (RAS) or nil if not available. +---@return integer|nil value +function getRAS() end + +--- Return built-in GPS data or nil if no GPS present. +---@return table|nil gpsData +function getTxGPS() end + +-- ========================================================================== +-- Unit constants +-- ========================================================================== + +---@type integer +UNIT_RAW = 0 +---@type integer +UNIT_VOLTS = 0 +---@type integer +UNIT_AMPS = 0 +---@type integer +UNIT_MILLIAMPS = 0 +---@type integer +UNIT_KTS = 0 +---@type integer +UNIT_METERS_PER_SECOND = 0 +---@type integer +UNIT_FEET_PER_SECOND = 0 +---@type integer +UNIT_KMH = 0 +---@type integer +UNIT_MPH = 0 +---@type integer +UNIT_METERS = 0 +---@type integer +UNIT_KM = 0 +---@type integer +UNIT_FEET = 0 +---@type integer +UNIT_CELSIUS = 0 +---@type integer +UNIT_FAHRENHEIT = 0 +---@type integer +UNIT_PERCENT = 0 +---@type integer +UNIT_MAH = 0 +---@type integer +UNIT_WATTS = 0 +---@type integer +UNIT_MILLIWATTS = 0 +---@type integer +UNIT_DB = 0 +---@type integer +UNIT_RPMS = 0 +---@type integer +UNIT_G = 0 +---@type integer +UNIT_DEGREE = 0 +---@type integer +UNIT_RADIANS = 0 +---@type integer +UNIT_MILLILITERS = 0 +---@type integer +UNIT_FLOZ = 0 +---@type integer +UNIT_MILLILITERS_PER_MINUTE = 0 +---@type integer +UNIT_HERTZ = 0 +---@type integer +UNIT_MS = 0 +---@type integer +UNIT_US = 0 +---@type integer +UNIT_DBM = 0 +---@type integer +UNIT_HOURS = 0 +---@type integer +UNIT_MINUTES = 0 +---@type integer +UNIT_SECONDS = 0 +---@type integer +UNIT_CELLS = 0 +---@type integer +UNIT_DATETIME = 0 +---@type integer +UNIT_GPS = 0 +---@type integer +UNIT_BITFIELD = 0 +---@type integer +UNIT_TEXT = 0 diff --git a/edgetx-lua-stdlib/time.d.lua b/edgetx-lua-stdlib/time.d.lua new file mode 100644 index 0000000..1b94d06 --- /dev/null +++ b/edgetx-lua-stdlib/time.d.lua @@ -0,0 +1,19 @@ +---@meta + +-- EdgeTX time and clock functions +-- Source: edgetx/radio/src/lua/api_general.cpp + +--- Return the time since the radio was started in multiples of 10ms. +--- Uses a 32-bit counter (enough for 497 days). +--- @since 2.0.0 +---@return integer timestamp 10ms tick count +function getTime() end + +--- Return current system date and time from the RTC. +--- @since 2.0.0 +---@return {year: integer, mon: integer, day: integer, hour: integer, min: integer, sec: integer, hour12: integer, suffix: string} +function getDateTime() end + +--- Return current RTC time as a Unix timestamp (seconds since epoch). +---@return integer seconds Unix timestamp from RTC +function getRtcTime() end diff --git a/edgetx-lua-stdlib/widget.d.lua b/edgetx-lua-stdlib/widget.d.lua new file mode 100644 index 0000000..eec8db7 --- /dev/null +++ b/edgetx-lua-stdlib/widget.d.lua @@ -0,0 +1,43 @@ +---@meta + +-- EdgeTX widget shared memory and option type constants +-- Source: edgetx/radio/src/lua/api_general.cpp + +-- ========================================================================== +-- Shared memory (color LCD only) +-- ========================================================================== + +--- Set a shared memory variable (for inter-widget communication). Color LCD only. +---@param index integer Variable index +---@param value integer +function setShmVar(index, value) end + +--- Get a shared memory variable value. Color LCD only. +---@param index integer Variable index +---@return integer|nil value nil if index is invalid +function getShmVar(index) end + +-- ========================================================================== +-- Widget option type constants +-- ========================================================================== + +---@type integer +COLOR = 0 +---@type integer +BOOL = 0 +---@type integer +STRING = 0 +---@type integer +TIMER = 0 +---@type integer +TEXT_SIZE = 0 +---@type integer +ALIGNMENT = 0 +---@type integer +SWITCH = 0 +---@type integer +SLIDER = 0 +---@type integer +CHOICE = 0 +---@type integer +FILE = 0 diff --git a/edgetx.yml b/edgetx.yml new file mode 100644 index 0000000..005ee9f --- /dev/null +++ b/edgetx.yml @@ -0,0 +1,31 @@ +package: + name: expresslrs + description: ExpressLRS Lua scripts and widgets for EdgeTX + license: GPL-3.0 + source_dir: src + min_edgetx_version: "2.12.0" + +libraries: + - name: ELRS + path: SCRIPTS/ELRS + + - name: CRSFSimulator + path: SCRIPTS/CRSFSimulator + dev: true + +tools: + - name: ExpressLRS + path: SCRIPTS/TOOLS/ExpressLRS + +widgets: + - name: ELRSTelemetry + path: WIDGETS/ELRSTelemetry + depends: + - ELRS + + - name: ELRSVTXAdmin + path: WIDGETS/ELRSVTXAdmin + depends: + - ELRS + exclude: + - presets.txt diff --git a/elrs.lua b/elrs.lua deleted file mode 100755 index 75f672a..0000000 --- a/elrs.lua +++ /dev/null @@ -1,958 +0,0 @@ --- TNS|ExpressLRS|TNE ----- ######################################################################### ----- # # ----- # Copyright (C) OpenTX, adapted for ExpressLRS # ------# # ----- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # ----- # # ----- ######################################################################### -local EXITVER = "-- EXIT (Lua r16) --" -local deviceId = 0xEE -local handsetId = 0xEF -local deviceName = nil -local lineIndex = 1 -local pageOffset = 0 -local edit = nil -local fieldPopup -local fieldTimeout = 0 -local loadQ = {} -local fieldChunk = 0 -local fieldData = nil -local fields = {} -local devices = {} -local goodBadPkt = "" -local elrsFlags = 0 -local elrsFlagsInfo = "" -local fields_count = 0 -local devicesRefreshTimeout = 50 -local currentFolderId = nil -local commandRunningIndicator = 1 -local expectChunksRemain = -1 -local deviceIsELRS_TX = nil -local linkstatTimeout = 100 -local titleShowWarn = nil -local titleShowWarnTimeout = 100 -local exitscript = 0 - -local COL1 -local COL2 -local maxLineIndex -local textYoffset -local textSize -local barTextSpacing - -local function allocateFields() - -- fields table is real fields, then the Other Devices item, then devices, then Exit/Back - fields = {} - for i=1, fields_count do - fields[i] = { } - end - fields[#fields+1] = {id=fields_count+1, name="Other Devices", parent=255, type=16} - fields[#fields+1] = {name=EXITVER, type=14} -end - -local function createDeviceFields() -- put other devices in the field list - -- move back button to the end of the list, so it will always show up at the bottom. - fields[fields_count + #devices + 2] = fields[#fields] - for i=1, #devices do - local parent = (devices[i].id == deviceId) and 255 or (fields_count+1) - fields[fields_count + 1 + i] = {id=devices[i].id, name=devices[i].name, parent=parent, type=15} - end -end - -local function reloadAllField() - fieldTimeout = 0 - fieldChunk = 0 - fieldData = nil - -- loadQ is actually a stack - loadQ = {} - for fieldId = fields_count, 1, -1 do - loadQ[#loadQ+1] = fieldId - end -end - -local function getField(line) - local counter = 1 - for i = 1, #fields do - local field = fields[i] - if currentFolderId == field.parent and not field.hidden then - if counter < line then - counter = counter + 1 - else - return field - end - end - end -end - -local function incrField(step) - local field = getField(lineIndex) - local min, max = 0, 0 - if field.type <= 8 then - min = field.min or 0 - max = field.max or 0 - step = (field.step or 1) * step - elseif field.type == 9 then - min = 0 - max = #field.values - 1 - end - - local newval = field.value - repeat - newval = newval + step - if newval < min then - newval = min - elseif newval > max then - newval = max - end - - -- keep looping until a non-blank selection value is found - if field.values == nil or #field.values[newval+1] ~= 0 then - field.value = newval - return - end - until (newval == min or newval == max) -end - --- Select the next or previous editable field -local function selectField(step) - local newLineIndex = lineIndex - local field - repeat - newLineIndex = newLineIndex + step - if newLineIndex <= 0 then - newLineIndex = #fields - elseif newLineIndex == 1 + #fields then - newLineIndex = 1 - pageOffset = 0 - end - field = getField(newLineIndex) - until newLineIndex == lineIndex or (field and field.name) - lineIndex = newLineIndex - if lineIndex > maxLineIndex + pageOffset then - pageOffset = lineIndex - maxLineIndex - elseif lineIndex <= pageOffset then - pageOffset = lineIndex - 1 - end -end - -local function fieldGetStrOrOpts(data, offset, last, isOpts) - -- For isOpts: Split a table of byte values (string) with ; separator into a table - -- Else just read a string until the first null byte - local r = last or (isOpts and {}) - local opt = '' - local vcnt = 0 - repeat - local b = data[offset] - offset = offset + 1 - - if not last then - if r and (b == 59 or b == 0) then -- ';' - r[#r+1] = opt - if opt ~= '' then - vcnt = vcnt + 1 - opt = '' - end - elseif b ~= 0 then - -- On firmwares that have constants defined for the arrow chars, use them in place of - -- the \xc0 \xc1 chars (which are OpenTX-en) - -- Use the table to convert the char, else use string.char if not in the table - opt = opt .. (({ - [192] = CHAR_UP or (__opentx and __opentx.CHAR_UP), - [193] = CHAR_DOWN or (__opentx and __opentx.CHAR_DOWN) - })[b] or string.char(b)) - end - end - until b == 0 - - return (r or opt), offset, vcnt, collectgarbage("collect") -end - -local function getDevice(id) - for _, device in ipairs(devices) do - if device.id == id then - return device - end - end -end - -local function fieldGetValue(data, offset, size) - local result = 0 - for i=0, size-1 do - result = bit32.lshift(result, 8) + data[offset + i] - end - return result -end - -local function reloadCurField() - local field = getField(lineIndex) - fieldTimeout = 0 - fieldChunk = 0 - fieldData = nil - loadQ[#loadQ+1] = field.id -end - --- UINT8/INT8/UINT16/INT16 + FLOAT + TEXTSELECT -local function fieldUnsignedLoad(field, data, offset, size, unitoffset) - field.value = fieldGetValue(data, offset, size) - field.min = fieldGetValue(data, offset+size, size) - field.max = fieldGetValue(data, offset+2*size, size) - --field.default = fieldGetValue(data, offset+3*size, size) - field.unit = fieldGetStrOrOpts(data, offset+(unitoffset or (4*size)), field.unit) - -- Only store the size if it isn't 1 (covers most fields / selection) - if size ~= 1 then - field.size = size - end -end - -local function fieldUnsignedToSigned(field, size) - local bandval = bit32.lshift(0x80, (size-1)*8) - field.value = field.value - bit32.band(field.value, bandval) * 2 - field.min = field.min - bit32.band(field.min, bandval) * 2 - field.max = field.max - bit32.band(field.max, bandval) * 2 - --field.default = field.default - bit32.band(field.default, bandval) * 2 -end - -local function fieldSignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedLoad(field, data, offset, size, unitoffset) - fieldUnsignedToSigned(field, size) - -- signed ints are INTdicated by a negative size - field.size = -size -end - -local function fieldIntLoad(field, data, offset) - -- Type is U8/I8/U16/I16, use that to determine the size and signedness - local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad - loadFn(field, data, offset, math.floor(field.type / 2) + 1) -end - -local function fieldIntSave(field) - local value = field.value - local size = field.size or 1 - -- Convert signed to 2s complement - if size < 0 then - size = -size - if value < 0 then - value = bit32.lshift(0x100, (size-1)*8) + value - end - end - - local frame = { deviceId, handsetId, field.id } - for i = size-1, 0, -1 do - frame[#frame + 1] = bit32.rshift(value, 8*i) % 256 - end - crossfireTelemetryPush(0x2D, frame) -end - -local function fieldIntDisplay(field, y, attr) - lcd.drawText(COL2, y, field.value .. field.unit, attr) -end - --- -- FLOAT -local function fieldFloatLoad(field, data, offset) - fieldSignedLoad(field, data, offset, 4, 21) - field.prec = data[offset+16] - if field.prec > 3 then - field.prec = 3 - end - field.step = fieldGetValue(data, offset+17, 4) - - -- precompute the format string to preserve the precision - field.fmt = "%." .. tostring(field.prec) .. "f" .. field.unit - -- Convert precision to a divider - field.prec = 10 ^ field.prec -end - -local function fieldFloatDisplay(field, y, attr) - lcd.drawText(COL2, y, string.format(field.fmt, field.value / field.prec), attr) -end - --- TEXT SELECTION -local function fieldTextSelLoad(field, data, offset) - local vcnt - local cached = field.nc == nil and field.values - field.values, offset, vcnt = fieldGetStrOrOpts(data, offset, cached, true) - -- 'Disable' the line if values only has one option in the list - if not cached then - field.grey = vcnt <= 1 - end - field.value = data[offset] - -- min max and default (offset+1 to 3) are not used on selections - -- units never uses cache - field.unit = fieldGetStrOrOpts(data, offset+4) - field.nc = nil -- use cache next time -end - -local function fieldTextSelDisplay_color(field, y, attr, color) - local val = field.values[field.value+1] or "ERR" - lcd.drawText(COL2, y, val, attr + color) - local strPix = lcd.sizeText and lcd.sizeText(val) or (10 * #val) - lcd.drawText(COL2 + strPix, y, field.unit, color) -end - -local function fieldTextSelDisplay_bw(field, y, attr) - lcd.drawText(COL2, y, field.values[field.value+1] or "ERR", attr) - lcd.drawText(lcd.getLastPos(), y, field.unit, 0) -end - --- STRING -local function fieldStringLoad(field, data, offset) - field.value, offset = fieldGetStrOrOpts(data, offset) - if #data >= offset then - field.maxlen = data[offset] - end -end - -local function fieldStringDisplay(field, y, attr) - lcd.drawText(COL2, y, field.value, attr) -end - -local function fieldFolderOpen(field) - currentFolderId = field.id - local backFld = fields[#fields] - backFld.name = "----BACK----" - -- Store the lineIndex and pageOffset to return to in the backFld - backFld.li = lineIndex - backFld.po = pageOffset - backFld.parent = currentFolderId - - lineIndex = 1 - pageOffset = 0 -end - -local function fieldFolderDeviceOpen(field) - -- crossfireTelemetryPush(0x28, { 0x00, 0xEA }) --broadcast with standard handset ID to get all node respond correctly - -- Make sure device fields are in the folder when it opens - createDeviceFields() - return fieldFolderOpen(field) -end - -local function fieldFolderDisplay(field,y ,attr) - lcd.drawText(COL1, y, "> " .. field.name, attr + BOLD) -end - -local function fieldCommandLoad(field, data, offset) - field.status = data[offset] - field.timeout = data[offset+1] - field.info = fieldGetStrOrOpts(data, offset+2) - if field.status == 0 then - fieldPopup = nil - end -end - -local function fieldCommandSave(field) - reloadCurField() - - if field.status ~= nil then - if field.status < 4 then - field.status = 1 - crossfireTelemetryPush(0x2D, { deviceId, handsetId, field.id, field.status }) - fieldPopup = field - fieldPopup.lastStatus = 0 - fieldTimeout = getTime() + field.timeout - end - end -end - -local function fieldCommandDisplay(field, y, attr) - lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) -end - -local function fieldBackExec(field) - if field.parent then - lineIndex = field.li or 1 - pageOffset = field.po or 0 - - field.name = EXITVER - field.parent = nil - field.li = nil - field.po = nil - currentFolderId = nil - else - exitscript = 1 - end -end - -local function changeDeviceId(devId) --change to selected device ID - local device = getDevice(devId) - if deviceId == devId and fields_count == device.fldcnt then return end - - deviceId = devId - elrsFlags = 0 - currentFolderId = nil - deviceName = device.name - fields_count = device.fldcnt - deviceIsELRS_TX = device.isElrs and devId == 0xEE or nil -- ELRS and ID is TX module - handsetId = deviceIsELRS_TX and 0xEF or 0xEA -- Address ELRS_LUA vs RADIO_TRANSMITTER - - allocateFields() - reloadAllField() -end - -local function fieldDeviceIdSelect(field) - return changeDeviceId(field.id) -end - -local function parseDeviceInfoMessage(data) - local id = data[2] - local newName, offset = fieldGetStrOrOpts(data, 3) - local device = getDevice(id) - if device == nil then - device = { id = id } - devices[#devices + 1] = device - end - device.name = newName - device.fldcnt = data[offset + 12] - device.isElrs = fieldGetValue(data, offset, 4) == 0x454C5253 -- SerialNumber = 'E L R S' - - if deviceId == id then - changeDeviceId(id) - end - -- DeviceList change while in Other Devices, refresh list - if currentFolderId == fields_count + 1 then - createDeviceFields() - end -end - -local functions = { - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --1 UINT8(0) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --2 INT8(1) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --3 UINT16(2) - { load=fieldIntLoad, save=fieldIntSave, display=fieldIntDisplay }, --4 INT16(3) - nil, - nil, - nil, - nil, - { load=fieldFloatLoad, save=fieldIntSave, display=fieldFloatDisplay }, --9 FLOAT(8) - { load=fieldTextSelLoad, save=fieldIntSave, display=nil }, --10 SELECT(9) - { load=fieldStringLoad, save=nil, display=fieldStringDisplay }, --11 STRING(10) editing NOTIMPL - { load=nil, save=fieldFolderOpen, display=fieldFolderDisplay }, --12 FOLDER(11) - { load=fieldStringLoad, save=nil, display=fieldStringDisplay }, --13 INFO(12) - { load=fieldCommandLoad, save=fieldCommandSave, display=fieldCommandDisplay }, --14 COMMAND(13) - { load=nil, save=fieldBackExec, display=fieldCommandDisplay }, --15 back/exit(14) - { load=nil, save=fieldDeviceIdSelect, display=fieldCommandDisplay }, --16 device(15) - { load=nil, save=fieldFolderDeviceOpen, display=fieldFolderDisplay }, --17 deviceFOLDER(16) -} - -local function parseParameterInfoMessage(data) - local fieldId = (fieldPopup and fieldPopup.id) or loadQ[#loadQ] - if data[2] ~= deviceId or data[3] ~= fieldId then - fieldData = nil - fieldChunk = 0 - return - end - local field = fields[fieldId] - local chunksRemain = data[4] - -- If no field or the chunksremain changed when we have data, don't continue - if not field or (fieldData and chunksRemain ~= expectChunksRemain) then - return - end - - local offset - -- If data is chunked, copy it to persistent buffer - if chunksRemain > 0 or fieldChunk > 0 then - fieldData = fieldData or {} - for i=5, #data do - fieldData[#fieldData + 1] = data[i] - data[i] = nil - end - offset = 1 - else - -- All data arrived in one chunk, operate directly on data - fieldData = data - offset = 5 - end - - if chunksRemain > 0 then - fieldChunk = fieldChunk + 1 - expectChunksRemain = chunksRemain - 1 - else - -- Field data stream is now complete, process into a field - loadQ[#loadQ] = nil - - if #fieldData > (offset + 2) then - field.id = fieldId - field.parent = (fieldData[offset] ~= 0) and fieldData[offset] or nil - field.type = bit32.band(fieldData[offset+1], 0x7f) - field.hidden = bit32.btest(fieldData[offset+1], 0x80) or nil - field.name, offset = fieldGetStrOrOpts(fieldData, offset+2, field.name) - if functions[field.type+1].load then - functions[field.type+1].load(field, fieldData, offset) - end - if field.min == 0 then field.min = nil end - if field.max == 0 then field.max = nil end - end - - fieldChunk = 0 - fieldData = nil - - -- Return value is if the screen should be updated - -- If deviceId is TX module, then the Bad/Good drives the update; for other - -- devices update each new item. and always update when the queue empties - return deviceId ~= 0xEE or #loadQ == 0 - end -end - -local function parseElrsInfoMessage(data) - if data[2] ~= deviceId then - fieldData = nil - fieldChunk = 0 - return - end - - local badPkt = data[3] - local goodPkt = (data[4]*256) + data[5] - local newFlags = data[6] - -- If flags are changing, reset the warning timeout to display/hide message immediately - if newFlags ~= elrsFlags then - elrsFlags = newFlags - titleShowWarnTimeout = 0 - end - elrsFlagsInfo = fieldGetStrOrOpts(data, 7) - - local state = (bit32.btest(elrsFlags, 1) and "C") or "-" - goodBadPkt = string.format("%u/%u %s", badPkt, goodPkt, state) -end - -local function parseElrsV1Message(data) - if (data[1] ~= 0xEA) or (data[2] ~= 0xEE) then - return - end - - -- local badPkt = data[9] - -- local goodPkt = (data[10]*256) + data[11] - -- goodBadPkt = string.format("%u/%u X", badPkt, goodPkt) - fieldPopup = {id = 0, status = 2, timeout = 0xFF, info = "ERROR: 1.x firmware"} - fieldTimeout = getTime() + 0xFFFF -end - -local function refreshNext(skipPush) - local command, data, forceRedraw - repeat - command, data = crossfireTelemetryPop() - if command == 0x29 then - parseDeviceInfoMessage(data) - elseif command == 0x2B then - if parseParameterInfoMessage(data) then - forceRedraw = true - end - if #loadQ > 0 then - fieldTimeout = 0 -- request next chunk immediately - elseif fieldPopup then - fieldTimeout = getTime() + fieldPopup.timeout - end - elseif command == 0x2D then - parseElrsV1Message(data) - elseif command == 0x2E then - parseElrsInfoMessage(data) - forceRedraw = true - end - until command == nil - - -- Don't even bother with return value, skipPush implies redraw - if skipPush then return end - - local time = getTime() - if fieldPopup then - if time > fieldTimeout and fieldPopup.status ~= 3 then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 6 }) -- lcsQuery - fieldTimeout = time + fieldPopup.timeout - end - elseif time > devicesRefreshTimeout and #devices == 0 then - forceRedraw = true -- handles initial screen draw - devicesRefreshTimeout = time + 100 -- 1s - crossfireTelemetryPush(0x28, { 0x00, 0xEA }) - elseif time > linkstatTimeout then - if deviceIsELRS_TX then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, 0x0, 0x0 }) --request linkstat - else - goodBadPkt = "" - end - linkstatTimeout = time + 100 - elseif time > fieldTimeout and fields_count ~= 0 then - if #loadQ > 0 then - crossfireTelemetryPush(0x2C, { deviceId, handsetId, loadQ[#loadQ], fieldChunk }) - fieldTimeout = time + (deviceIsELRS_TX and 50 or 500) -- 0.5s for local / 5s for remote devices - end - end - - if time > titleShowWarnTimeout then - -- if elrsFlags bit set is bit higher than bit 0 and bit 1, it is warning flags - titleShowWarn = (elrsFlags > 3 and not titleShowWarn) or nil - titleShowWarnTimeout = time + 100 - forceRedraw = true - end - - return forceRedraw -end - -local lcd_title -- holds function that is color/bw version -local function lcd_title_color() - lcd.clear() - - local EBLUE = lcd.RGB(0x43, 0x61, 0xAA) - local EGREEN = lcd.RGB(0x9f, 0xc7, 0x6f) - local EGREY1 = lcd.RGB(0x91, 0xb2, 0xc9) - local EGREY2 = lcd.RGB(0x6f, 0x62, 0x7f) - - -- Field display area (white w/ 2px green border) - lcd.setColor(CUSTOM_COLOR, EGREEN) - lcd.drawRectangle(0, 0, LCD_W, LCD_H, CUSTOM_COLOR) - lcd.drawRectangle(1, 0, LCD_W - 2, LCD_H - 1, CUSTOM_COLOR) - -- title bar - lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, EGREY1) - lcd.drawFilledRectangle(LCD_W - textSize, 0, textSize, barHeight, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, EGREY2) - lcd.drawRectangle(LCD_W - textSize, 0, textSize, barHeight - 1, CUSTOM_COLOR) - lcd.drawRectangle(LCD_W - textSize, 1 , textSize - 1, barHeight - 2, CUSTOM_COLOR) -- left and bottom line only 1px, make it look bevelled - lcd.setColor(CUSTOM_COLOR, BLACK) - if titleShowWarn then - lcd.drawText(COL1 + 1, barTextSpacing, elrsFlagsInfo, CUSTOM_COLOR) - else - lcd.drawText(COL1 + 1, barTextSpacing, deviceName, CUSTOM_COLOR) - lcd.drawText(LCD_W - 5, barTextSpacing, goodBadPkt, RIGHT + BOLD + CUSTOM_COLOR) - end - -- progress bar - if #loadQ > 0 and fields_count > 0 then - local barW = (COL2-4) * (fields_count - #loadQ) / fields_count - lcd.setColor(CUSTOM_COLOR, EBLUE) - lcd.drawFilledRectangle(2, barTextSpacing/2+textSize, barW, barTextSpacing, CUSTOM_COLOR) - lcd.setColor(CUSTOM_COLOR, WHITE) - lcd.drawFilledRectangle(2+barW, barTextSpacing/2+textSize, COL2-2-barW, barTextSpacing, CUSTOM_COLOR) - end -end - -local function lcd_title_bw() - lcd.clear() - -- B&W screen - local barHeight = 9 - if not titleShowWarn then - lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) - lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight-1, SOLID, INVERS) - end - - if #loadQ > 0 and fields_count > 0 then - lcd.drawFilledRectangle(COL2, 0, LCD_W, barHeight, GREY_DEFAULT) - lcd.drawGauge(0, 0, COL2, barHeight, fields_count - #loadQ, fields_count, 0) - else - lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) - if titleShowWarn then - lcd.drawText(COL1, 1, elrsFlagsInfo, INVERS) - else - lcd.drawText(COL1, 1, deviceName, INVERS) - end - end -end - -local function lcd_warn() - lcd.drawText(COL1, textSize*2, "Error:") - lcd.drawText(COL1, textSize*3, elrsFlagsInfo) - lcd.drawText(LCD_W/2, textSize*5, "[OK]", BLINK + INVERS + CENTER) -end - -local function reloadRelatedFields(field) - -- Reload the parent folder to update the description - if field.parent then - loadQ[#loadQ+1] = field.parent - fields[field.parent].name = nil - end - - -- Reload all editable fields at the same level as well as the parent item - for fieldId = fields_count, 1, -1 do - -- Skip this field, will be added to end - local fldTest = fields[fieldId] - local fldType = fldTest.type or 99 -- type could be nil if still loading - if fieldId ~= field.id - and fldTest.parent == field.parent - and (fldType < 11 or fldType == 12) then -- ignores FOLDER/COMMAND/devices/EXIT - fldTest.nc = true -- "no cache" the options - loadQ[#loadQ+1] = fieldId - end - end - - -- Reload this field - loadQ[#loadQ+1] = field.id - -- with a short delay to allow the module EEPROM to commit - fieldTimeout = getTime() + 20 - -- Also push the next bad/good update further out - linkstatTimeout = fieldTimeout + 100 -end - -local function handleDevicePageEvent(event) - if #fields == 0 then --if there is no field yet - return - else - if fields[#fields].name == nil then --if back button is not assigned yet, means there is no field yet. - return - end - end - - if event == EVT_VIRTUAL_EXIT then -- Cancel edit / go up a folder / reload all - if edit then - edit = nil - reloadCurField() - else - if currentFolderId == nil and #loadQ == 0 then -- only do reload if we're in the root folder and finished loading - if deviceId ~= 0xEE then - changeDeviceId(0xEE) - else - reloadAllField() - end - crossfireTelemetryPush(0x28, { 0x00, 0xEA }) - else - fieldBackExec(fields[#fields]) - end - end - elseif event == EVT_VIRTUAL_ENTER then -- toggle editing/selecting current field - if elrsFlags > 0x1F then - elrsFlags = 0 - crossfireTelemetryPush(0x2D, { deviceId, handsetId, 0x2E, 0x00 }) - else - local field = getField(lineIndex) - if field and field.name then - -- Editable fields - if not field.grey and field.type < 10 then - edit = not edit - if not edit then - reloadRelatedFields(field) - end - end - if not edit then - if functions[field.type+1].save then - functions[field.type+1].save(field) - end - end - end - end - elseif edit then - if event == EVT_VIRTUAL_NEXT then - incrField(1) - elseif event == EVT_VIRTUAL_PREV then - incrField(-1) - end - else - if event == EVT_VIRTUAL_NEXT then - selectField(1) - elseif event == EVT_VIRTUAL_PREV then - selectField(-1) - end - end -end - --- Main -local function runDevicePage(event) - handleDevicePageEvent(event) - - lcd_title() - - if #devices > 1 then -- show other device folder - fields[fields_count+1].parent = nil - end - if elrsFlags > 0x1F then - lcd_warn() - else - for y = 1, maxLineIndex+1 do - local field = getField(pageOffset+y) - if not field then - break - elseif field.name ~= nil then - local attr = lineIndex == (pageOffset+y) - and ((edit and BLINK or 0) + INVERS) - or 0 - local color = field.grey and COLOR_THEME_DISABLED or 0 - if field.type < 11 or field.type == 12 then -- if not folder, command, or back - lcd.drawText(COL1, y*textSize+textYoffset, field.name, color) - end - if functions[field.type+1].display then - functions[field.type+1].display(field, y*textSize+textYoffset, attr, color) - end - end - end - end -end - -local function popupCompat(t, m, e) - -- Only use 2 of 3 arguments for older platforms - return popupConfirmation(t, e) -end - -local function runPopupPage(event) - if event == EVT_VIRTUAL_EXIT then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 5 }) -- lcsCancel - fieldTimeout = getTime() + 200 -- 2s - end - - if fieldPopup.status == 0 and fieldPopup.lastStatus ~= 0 then -- stopped - popupCompat(fieldPopup.info, "Stopped!", event) - reloadAllField() - fieldPopup = nil - elseif fieldPopup.status == 3 then -- confirmation required - local result = popupCompat(fieldPopup.info, "PRESS [OK] to confirm", event) - fieldPopup.lastStatus = fieldPopup.status - if result == "OK" then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 4 }) -- lcsConfirmed - fieldTimeout = getTime() + fieldPopup.timeout -- we are expecting an immediate response - fieldPopup.status = 4 - elseif result == "CANCEL" then - fieldPopup = nil - end - elseif fieldPopup.status == 2 then -- running - if fieldChunk == 0 then - commandRunningIndicator = (commandRunningIndicator % 4) + 1 - end - local result = popupCompat(fieldPopup.info .. " [" .. string.sub("|/-\\", commandRunningIndicator, commandRunningIndicator) .. "]", "Press [RTN] to exit", event) - fieldPopup.lastStatus = fieldPopup.status - if result == "CANCEL" then - crossfireTelemetryPush(0x2D, { deviceId, handsetId, fieldPopup.id, 5 }) -- lcsCancel - fieldTimeout = getTime() + fieldPopup.timeout -- we are expecting an immediate response - fieldPopup = nil - end - end -end - -local function touch2evt(event, touchState) - -- Convert swipe events to normal events Left/Right/Up/Down -> EXIT/ENTER/PREV/NEXT - -- PREV/NEXT are swapped if editing - -- TAP is converted to ENTER - touchState = touchState or {} - return (touchState.swipeLeft and EVT_VIRTUAL_EXIT) - or (touchState.swipeRight and EVT_VIRTUAL_ENTER) - or (touchState.swipeUp and (edit and EVT_VIRTUAL_NEXT or EVT_VIRTUAL_PREV)) - or (touchState.swipeDown and (edit and EVT_VIRTUAL_PREV or EVT_VIRTUAL_NEXT)) - or (event == EVT_TOUCH_TAP and EVT_VIRTUAL_ENTER) -end - -local function setLCDvar() - -- Set the title function depending on if LCD is color, and free the other function and - -- set textselection unit function, use GetLastPost or sizeText - if (lcd.RGB ~= nil) then - lcd_title = lcd_title_color - functions[10].display = fieldTextSelDisplay_color - else - lcd_title = lcd_title_bw - functions[10].display = fieldTextSelDisplay_bw - touch2evt = nil - end - lcd_title_color = nil - lcd_title_bw = nil - fieldTextSelDisplay_bw = nil - fieldTextSelDisplay_color = nil - -- Determine if popupConfirmation takes 3 arguments or 2 - -- if pcall(popupConfirmation, "", "", EVT_VIRTUAL_EXIT) then - -- major 1 is assumed to be FreedomTX - local _, _, major = getVersion() - if major ~= 1 then - popupCompat = popupConfirmation - end - - if (lcd.RGB ~= nil) then - local ver, radio, maj, minor, rev, osname = getVersion() - - if osname ~= nil and osname == "EdgeTX" then - textWidth, textSize = lcd.sizeText("Qg") -- determine standard font height for EdgeTX - else - textSize = 21 -- use this for OpenTX - end - - COL1 = 3 - COL2 = LCD_W/2 - barTextSpacing = 4 - barHeight = textSize + barTextSpacing + barTextSpacing - textYoffset = 2 * barTextSpacing + 2 - maxLineIndex = math.floor(((LCD_H - barHeight - textYoffset) / textSize)) - 1 - else - if LCD_W == 212 then - COL2 = 110 - else - COL2 = 70 - end - if LCD_H == 96 then - maxLineIndex = 9 - else - maxLineIndex = 6 - end - COL1 = 0 - textYoffset = 3 - textSize = 8 - end -end - -local function setMock() - -- Setup fields to display if running in Simulator - local _, rv = getVersion() - if string.sub(rv, -5) ~= "-simu" then return end - local mock = loadScript("mockup/elrsmock.lua") - if mock == nil then return end - fields, goodBadPkt, deviceName = mock() - fields_count = #fields - 1 - loadQ = { fields_count } - deviceIsELRS_TX = true -end - -local function checkCrsfModule() - -- Loop through the modules and look for one set to CRSF (5) - for modIdx = 0, 1 do - local mod = model.getModule(modIdx) - if mod and (mod.Type == nil or mod.Type == 5) then - -- CRSF found, put module type in Loading message - local modDescrip = (mod.Type == nil) and " awaiting" or (modIdx == 0) and " Internal" or " External" - -- Prefix with "Lua rXXX" from between EXITVER parens - deviceName = string.match(EXITVER, "%((.*)%)") .. modDescrip .. " TX..." - checkCrsfModule = nil - return 0 - end - end - - -- No CRSF module found, save an error message for run() - lcd.clear() - local y = 0 - lcd.drawText(2, y, " No ExpressLRS", MIDSIZE) - y = y + (textSize * 2) - 2 - local msgs = { - " Enable a CRSF Internal", - " or External module in", - " Model settings", - " If module is internal", - " also set Internal RF to", - " CRSF in SYS->Hardware", - } - for i, msg in ipairs(msgs) do - lcd.drawText(2, y, msg) - y = y + textSize - if i == 3 then - lcd.drawLine(0, y, LCD_W, y, SOLID, INVERS) - y = y + 2 - end - end - - return 0 -end - --- Init -local function init() - setLCDvar() - setMock() - setLCDvar = nil - setMock = nil -end - --- Main -local function run(event, touchState) - if event == nil then return 2 end - if checkCrsfModule then return checkCrsfModule() end - - event = (touch2evt and touch2evt(event, touchState)) or event - -- If ENTER pressed, skip any pushing this loop to reserve queue for the save command - local forceRedraw = refreshNext(event == EVT_VIRTUAL_ENTER) - - if fieldPopup ~= nil then - runPopupPage(event) - elseif event ~= 0 or forceRedraw or edit then - runDevicePage(event) - end - - return exitscript -end - -return { init=init, run=run } diff --git a/mockup/README.md b/mockup/README.md deleted file mode 100644 index c7a0597..0000000 --- a/mockup/README.md +++ /dev/null @@ -1,10 +0,0 @@ -This file is for development purposes only and provides field definitions for filling the screen in the OpenTX Companion Simulator. Users do not need this file on their handset's SD card! - -### Using -Copy the entire mockup directory into the same directory as `elrsV2.lua` on your hard drive where you've set the OpenTX Companion "SD Structure Path". The SD structure path should look like this: -``` -SCRIPTS/TOOLS/elrsV2.lua -SCRIPTS/TOOLS/mockup/elrsmock.lua -SCRIPTS/TOOLS/mockup/README.md <- this file -``` -When you execute elrsV2.lua, the screen will be populated with fake ELRS Lua config fields. \ No newline at end of file diff --git a/mockup/elrsmock.lua b/mockup/elrsmock.lua deleted file mode 100644 index 73860c9..0000000 --- a/mockup/elrsmock.lua +++ /dev/null @@ -1,24 +0,0 @@ -return { - {name='Packet Rate', id=0, type=9, values={'250(-108dBm)','500(-105dBm)'}, value=1, unit='Hz'}, - {name='Telem Ratio', id=1, type=9, values={'Off','1:128'}, value=1, unit=''}, - {name='Switch Mode', id=2, type=9, values={'Hybrid','Wide'}, value=1, unit=''}, - {name='Model Match', id=3, type=9, values={'Off',''}, grey=true, value=0, unit='(ID:1)'}, - {name='TX Power', id=4, type=11}, - {name='Max Power', id=5, type=9, parent=4, values={'10','25','50'}, value=2, unit='mW'}, - {name='Dynamic', id=6, type=9, parent=4, values={'Off','On','AUX9'}, value=1, unit=''}, - {name='Fan Thresh', id=7, type=9, parent=4, values={'10mW','25mW','50mW', '250mW'}, value=3, unit=''}, - {name='VTX Administrator', id=8, type=11}, - {name='Band', id=9, type=9, parent=8, values={'Off', 'A', 'B', 'F', 'R', 'L'}, value=0, unit=''}, - {name='Channel', id=10, type=0, parent=8, value=1, step=1, min=1, max=8, unit=''}, - {name='Pwr Lvl', id=11, type=9, parent=8, values={'-', '1', '2', '3' }, value=0, unit=''}, - {name='Pitmode', id=12, type=9, parent=8, values={'Off', 'On'}, value=0, unit=''}, - {name='Send VTx', id=13, type=13, parent=8}, - {name='Bind', id=14, type=13}, - {name='Wifi Update', id=15, type=13}, - {name='BLE Joystick', id=16, type=13}, - {name='master', id=17, type=16, value='f00fcb'}, - {name='Float Tst', id=18, type=8, value=-15, step=5, prec=1000, min=-50, max=50, unit='flt', fmt='%.3fflt'}, - - {name="----BACK----", type=14, parent=255}, - {name="----EXIT----", type=14, exit = true} -}, "0/500 C", "ExpressLRS TX" \ No newline at end of file diff --git a/screenshots/tool_main.png b/screenshots/tool_main.png new file mode 100644 index 0000000..41c0101 Binary files /dev/null and b/screenshots/tool_main.png differ diff --git a/screenshots/tool_main_bw.png b/screenshots/tool_main_bw.png new file mode 100644 index 0000000..b87dccf Binary files /dev/null and b/screenshots/tool_main_bw.png differ diff --git a/screenshots/widget_telemetry_fullscren.png b/screenshots/widget_telemetry_fullscren.png new file mode 100644 index 0000000..3e2c8ed Binary files /dev/null and b/screenshots/widget_telemetry_fullscren.png differ diff --git a/screenshots/widget_vtxadmin_fullscreen.png b/screenshots/widget_vtxadmin_fullscreen.png new file mode 100644 index 0000000..923e4a6 Binary files /dev/null and b/screenshots/widget_vtxadmin_fullscreen.png differ diff --git a/screenshots/widgets.png b/screenshots/widgets.png new file mode 100644 index 0000000..2c2ff91 Binary files /dev/null and b/screenshots/widgets.png differ diff --git a/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua new file mode 100644 index 0000000..dc9b120 --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -0,0 +1,1364 @@ +-- ============================================================================ +-- CRSF Simulator: Packet-level mock for crossfireTelemetryPop/Push +-- ============================================================================ +-- This module simulates the CRSF protocol at the packet level, allowing the +-- ELRS Lua script to exercise the full communication flow (device discovery, +-- parameter loading, value writes, ELRS status) in the EdgeTX simulator. +-- +-- Usage: loaded by elrs_lvgl3.lua setMock() when running in simulator mode. +-- Returns a table with { pop, push, moduleFound } fields. +-- ============================================================================ + +-- ============================================================================ +-- Configuration: Change scenario here to test different states +-- ============================================================================ + +-- Scenarios: +-- "normal" TX + RX connected. Happy path with full telemetry, link +-- stats, and all parameters from both devices. +-- "no_telemetry" TX present but no RX telemetry. Shows "No telemetry" in subtitle. +-- No receiver device in Other Devices list. +-- "reconnect" Starts disconnected, then transitions to connected after +-- ~5 seconds. Tests auto-discovery of Other Devices on +-- reconnect without restarting the script. +-- "model_mismatch" TX + RX connected but with Model ID mismatch flag set. +-- Triggers the Model Mismatch warning dialog. +-- "armed" TX + RX connected with the "is Armed" warning flag set. +-- Shows armed warning in subtitle. +-- "slow_loading" TX + RX connected but PARAMETER_READ responses are +-- delayed by ~2 seconds each. Tests how the UI renders +-- during slow field discovery (e.g. "Loading..." states +-- in minimized widgets, full-screen subtitle updates). +-- "no_module" No CRSF module found at all. Triggers the "No Module +-- Found" error dialog immediately. +local config = { + scenario = "normal", +} + +-- ============================================================================ +-- CRSF Protocol Constants (local copies, independent of Protocol.CRSF) +-- ============================================================================ + +local CRSF = { + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- Field types0 + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- Command steps + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, +} + +-- ============================================================================ +-- Rate configuration table (matches SX128X 2.4GHz from common.cpp) +-- Maps Packet Rate option index to Hz, interval (µs), and default TLM ratio +-- TLM ratio indices into "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race": +-- 0=Std, 1=Off, 2=1:128, 3=1:64, 4=1:32, 5=1:16, 6=1:8, 7=1:4, 8=1:2, 9=Race +-- ============================================================================ + +local rateConfigs = { + [0] = { hz = 50, interval = 20000, defaultTlm = 5 }, -- TLM_RATIO_1_16 + [1] = { hz = 150, interval = 6666, defaultTlm = 4 }, -- TLM_RATIO_1_32 + [2] = { hz = 250, interval = 4000, defaultTlm = 3 }, -- TLM_RATIO_1_64 + [3] = { hz = 500, interval = 2000, defaultTlm = 2 }, -- TLM_RATIO_1_128 +} + +-- ============================================================================ +-- FIFO Packet Queue +-- ============================================================================ + +local packetQueue = {} +local queueHead = 1 + +-- Deferred packets simulate OTA relay delay (e.g., RX DEVICE_INFO arriving +-- later than TX DEVICE_INFO). They are delivered in the NEXT poll cycle, +-- after the main queue has been drained and a nil has been returned. +local deferredQueue = {} +local deferredReady = false + +-- BW/FreedomTX compatibility: provide a local analogue to table.remove(). +-- Supports remove(tbl) and remove(tbl, idx) semantics. +local function tableRemove(tbl, idx) + if table and table.remove then + return table.remove(tbl, idx) + end + + local n = #tbl + local pos = idx + if pos == nil then + pos = n + end + if pos < 1 or pos > n then + return nil + end + + local removed = tbl[pos] + for i = pos, n - 1 do + tbl[i] = tbl[i + 1] + end + tbl[n] = nil + return removed +end + +-- Slow loading scenario: time-delayed response queue. +-- PARAMETER_READ responses are held here until their delivery time, then +-- promoted to the main queue so the Lua script sees realistic latency. +local SLOW_LOADING_DELAY_TICKS = 200 -- 2 seconds per field (getTime() at 10ms/tick) +local delayedResponseQueue = {} + +-- Deferred folder name updates simulate the firmware event loop gap: +-- PARAMETER_WRITE callbacks set config values immediately, but +-- updateFolderNames() runs on the NEXT event loop iteration. +-- A PARAMETER_READ arriving before that gets stale dynName. +-- Delay is time-based (getTime() ticks, 10ms each) to be independent of +-- how often mockPop is called within a single Protocol.poll() cycle. +local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20ms delay +local folderNamesReadyAt = 0 +local folderNamesDevice = nil + +local function queuePush(command, data) + packetQueue[#packetQueue + 1] = { command = command, data = data } +end + +local function queuePushDeferred(command, data) + deferredQueue[#deferredQueue + 1] = { command = command, data = data } +end + +local function queuePop() + -- Serve from main queue first + if queueHead <= #packetQueue then + local pkt = packetQueue[queueHead] + queueHead = queueHead + 1 + deferredReady = false + return pkt.command, pkt.data + end + + -- Main queue empty, reset it + packetQueue = {} + queueHead = 1 + + -- Serve deferred packets only after a nil has been returned (next poll cycle) + if deferredReady and #deferredQueue > 0 then + local pkt = tableRemove(deferredQueue, 1) + ---@diagnostic disable-next-line: need-check-nil + return pkt.command, pkt.data + end + + -- Mark deferred as ready for the next poll cycle + if #deferredQueue > 0 then + deferredReady = true + end + + return nil +end + +-- ============================================================================ +-- String-to-bytes helper +-- ============================================================================ + +local function appendString(tbl, str) + for i = 1, #str do + tbl[#tbl + 1] = string.byte(str, i) + end + tbl[#tbl + 1] = 0 -- null terminator +end + +local function appendU32BE(tbl, val) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 24), 0xFF) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 16), 0xFF) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 8), 0xFF) + tbl[#tbl + 1] = bit32.band(val, 0xFF) +end + +local function appendU16BE(tbl, val) + tbl[#tbl + 1] = bit32.band(bit32.rshift(val, 8), 0xFF) + tbl[#tbl + 1] = bit32.band(val, 0xFF) +end + +-- ============================================================================ +-- CRSF Packet Encoders +-- ============================================================================ + +--- Encode a DEVICE_INFO response packet (frame type 0x29) +-- @param device table with: id, name, serialNo, hwVer, swVer, fieldCount +-- @param destAddr destination address (usually ADDRESS_RADIO_TRANSMITTER) +-- @return data table suitable for queuePush(FRAMETYPE_DEVICE_INFO, data) +local function encodeDeviceInfo(device, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = device.id + -- Device name (null-terminated) + appendString(data, device.name) + -- Serial number (4 bytes BE) + appendU32BE(data, device.serialNo or CRSF.ELRS_SERIAL_ID) + -- Hardware version (4 bytes BE) + appendU32BE(data, device.hwVer or 0) + -- Software version (4 bytes BE) + appendU32BE(data, device.swVer or 0x00030500) -- 3.5.0 + -- Field count + data[#data + 1] = device.fieldCount + -- Parameter version + data[#data + 1] = 0 + return data +end + +--- Encode a PARAMETER_SETTINGS_ENTRY packet (frame type 0x2B) +-- Encodes one chunk of a parameter. Currently only single-chunk (chunk 0) supported. +-- @param device the device table (for id) +-- @param param the parameter definition table +-- @param chunk chunk index (0 for single-chunk params) +-- @param destAddr destination address +-- @return data table suitable for queuePush(FRAMETYPE_PARAMETER_SETTINGS_ENTRY, data) +local function encodeParameterEntry(device, param, _chunk, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = device.id + data[3] = param.id -- Field ID + data[4] = 0 -- Chunks remaining (0 = single chunk) + data[5] = param.parent or 0 -- Parent ID (0 = root) + data[6] = param.type -- Type byte (with hidden flag if needed) + if param.hidden then + data[6] = bit32.bor(data[6], 0x80) + end + + -- Parameter name (null-terminated) — use dynamic name if set (e.g. folder summaries) + appendString(data, param.dynName or param.name) + + -- Type-specific value data + local t = bit32.band(param.type, 0x7F) + + if t == CRSF.TEXT_SELECTION then + -- Options string (semicolon-separated, null-terminated) + appendString(data, param.options) + -- Value (current selection index) + data[#data + 1] = param.value or 0 + -- Min + data[#data + 1] = 0 + -- Max (count of options - 1) + local optCount = 1 + for i = 1, #param.options do + if string.byte(param.options, i) == 59 then -- ';' + optCount = optCount + 1 + end + end + data[#data + 1] = optCount - 1 + -- Default + data[#data + 1] = 0 + -- Units (null-terminated) + appendString(data, param.units or "") + elseif t == CRSF.COMMAND then + -- Status + data[#data + 1] = param.status or CRSF.CMD_IDLE + -- Timeout (in 10ms ticks, 200 = 2s) + data[#data + 1] = param.timeout or 200 + -- Info string (null-terminated) + appendString(data, param.info or "") + elseif t == CRSF.FOLDER then + -- Folder contains a list of child parameter IDs terminated by 0xFF. + -- This allows the Lua script to know which fields to load for this folder. + -- We need the device context to scan for children. + if param._device then + for _, p in ipairs(param._device.params) do + if (param.id == 0 and (p.parent == 0 or p.parent == nil)) or (param.id ~= 0 and p.parent == param.id) then + data[#data + 1] = p.id + end + end + end + data[#data + 1] = 0xFF -- terminator + elseif t == CRSF.INFO then + appendString(data, param.value or "") + elseif t == CRSF.STRING then + appendString(data, param.value or "") + data[#data + 1] = param.maxlen or 32 + elseif t == CRSF.UINT8 then + -- value, min, max (1 byte each) + data[#data + 1] = param.value or 0 + data[#data + 1] = param.min or 0 + data[#data + 1] = param.max or 255 + -- default + data[#data + 1] = param.default or 0 + -- units + appendString(data, param.units or "") + elseif t == CRSF.INT8 then + -- Same as UINT8 but values may be signed (stored as unsigned in wire format) + local v = param.value or 0 + if v < 0 then + v = v + 256 + end + local mn = param.min or 0 + if mn < 0 then + mn = mn + 256 + end + local mx = param.max or 127 + if mx < 0 then + mx = mx + 256 + end + data[#data + 1] = v + data[#data + 1] = mn + data[#data + 1] = mx + data[#data + 1] = param.default or 0 + appendString(data, param.units or "") + elseif t == CRSF.UINT16 or t == CRSF.INT16 then + -- value, min, max (2 bytes BE each) + appendU16BE(data, param.value or 0) + appendU16BE(data, param.min or 0) + appendU16BE(data, param.max or 65535) + -- default (2 bytes) + appendU16BE(data, param.default or 0) + appendString(data, param.units or "") + elseif t == CRSF.FLOAT then + -- value, min, max, default (4 bytes BE each), precision (1 byte), step (4 bytes BE) + appendU32BE(data, param.value or 0) + appendU32BE(data, param.min or 0) + appendU32BE(data, param.max or 0) + appendU32BE(data, param.default or 0) + data[#data + 1] = param.prec or 0 + appendU32BE(data, param.step or 1) + appendString(data, param.units or "") + end + + return data +end + +--- Encode an ELRS_STATUS packet (frame type 0x2E) +-- @param deviceId source device address +-- @param destAddr destination address +-- @param badPkts bad packets count (uint8) +-- @param goodPkts good packets count (uint16) +-- @param flags warning flags byte +-- @param flagsInfo warning message string +-- @return data table +local function encodeElrsStatus(deviceId, destAddr, badPkts, goodPkts, flags, flagsInfo) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_RADIO_TRANSMITTER + data[2] = deviceId + data[3] = badPkts or 0 + -- Good packets as uint16 BE + appendU16BE(data, goodPkts or 0) + data[#data + 1] = flags or 0 + -- Warning info string (null-terminated) + appendString(data, flagsInfo or "") + return data +end + +-- ============================================================================ +-- TX Device Definition (address 0xEE) +-- Matches TXModuleParameters.cpp parameter structure +-- ============================================================================ + +local txDevice = { + id = CRSF.ADDRESS_CRSF_TRANSMITTER, + name = "TX16S MK3", + serialNo = CRSF.ELRS_SERIAL_ID, + hwVer = 0, + swVer = 0x00030500, -- 3.5.0 + fieldCount = 23, -- total parameter count + params = { + { + id = 1, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Packet Rate", + options = "50(-117dBm);150(-112dBm);250(-108dBm);500(-105dBm)", + value = 2, + units = "Hz", + }, + { + id = 2, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Telem Ratio", + options = "Std;Off;1:128;1:64;1:32;1:16;1:8;1:4;1:2;Race", + value = 0, + units = " (1:64)", + }, + { + id = 3, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Switch Mode", + options = "Hybrid;Wide", + value = 1, + units = "", + }, + { + id = 4, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Model Match", + options = "Off;On", + value = 0, + units = "(ID: 1)", + }, + { + id = 5, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Antenna Mode", + options = "Gemini;Ant 1;Ant 2;Switch", + value = 0, + units = "", + }, + + -- TX Power folder + { id = 6, parent = 0, type = CRSF.FOLDER, name = "TX Power" }, + { + id = 7, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Max Power", + options = "10/10;25/25;25/50;25/100;25/250;25/500;25/1000;25/2000", + value = 3, + units = "mW", + }, + { + id = 8, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Dynamic", + options = "Off;Dyn;AUX9;AUX10;AUX11;AUX12", + value = 1, + units = "", + }, + { + id = 9, + parent = 6, + type = CRSF.TEXT_SELECTION, + name = "Fan Thresh", + options = "10mW;25mW;50mW;100mW;250mW;500mW;1000mW;2000mW;Never", + value = 3, + units = "", + }, + + -- VTX Administrator folder + { id = 10, parent = 0, type = CRSF.FOLDER, name = "VTX Administrator" }, + { + id = 11, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Band", + options = "Off;A;B;E;F;R;L", + value = 5, + units = "", + }, + { id = 12, parent = 10, type = CRSF.UINT8, name = "Channel", value = 1, min = 1, max = 8, units = "" }, + { + id = 13, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Pwr Lvl", + options = "-;1;2;3;4;5;6;7;8", + value = 0, + units = "", + }, + { + id = 14, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Pitmode", + options = "Off;On", + value = 0, + units = "", + }, + { + id = 15, + parent = 10, + type = CRSF.COMMAND, + name = "Send VTx", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, + + -- WiFi Connectivity folder + { id = 16, parent = 0, type = CRSF.FOLDER, name = "WiFi Connectivity" }, + { + id = 17, + parent = 16, + type = CRSF.COMMAND, + name = "Enable WiFi", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + persistent = true, + }, -- runs until cancelled + { + id = 18, + parent = 16, + type = CRSF.COMMAND, + name = "Enable Rx WiFi", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + persistent = true, + }, -- runs until cancelled + + -- Root-level commands and info + { + id = 19, + parent = 0, + type = CRSF.COMMAND, + name = "Bind", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, + + -- Editable string field + { + id = 20, + parent = 0, + type = CRSF.STRING, + name = "Bind Phrase", + value = "default", + maxlen = 16, + }, + + -- Float field (scaled integer with precision) + { + id = 21, + parent = 0, + type = CRSF.FLOAT, + name = "Freq Offset", + value = 0, + min = -5000, + max = 5000, + default = 0, + prec = 2, + step = 1, + units = "kHz", + }, + + -- Bad/Good (hidden from ELRS Lua, visible to other UIs) + { id = 22, parent = 0, type = CRSF.INFO, name = "Bad/Good", value = "0/250", hidden = true }, + + -- Version + regulatory domain (name = version+domain, value = commit hash) + { id = 23, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, + }, +} + +-- ============================================================================ +-- RX Device Definition (address 0xEC) +-- Matches RXParameters.cpp parameter structure +-- ============================================================================ + +local rxDevice = { + id = CRSF.ADDRESS_CRSF_RECEIVER, + name = "ELRS 2400RX", + serialNo = CRSF.ELRS_SERIAL_ID, + hwVer = 0, + swVer = 0x00030500, -- 3.5.0 + fieldCount = 22, -- total parameter count + params = { + { + id = 1, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Protocol", + options = "CRSF;Inverted CRSF;SBUS;Inverted SBUS;SUMD;DJI RS Pro;HoTT Telemetry;MAVLink;DisplayPort;GPS", + value = 0, + units = "", + }, + { + id = 2, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "SBUS failsafe", + options = "No Pulses;Last Pos", + value = 0, + units = "", + }, + { + id = 3, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Ant. Mode", + options = "Antenna A;Antenna B;Diversity", + value = 2, + units = "", + }, + { + id = 4, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Tlm Power", + options = "10;25;50;100;250;MatchTX", + value = 2, + units = "mW", + }, + + -- Team Race folder + { id = 5, parent = 0, type = CRSF.FOLDER, name = "Team Race" }, + { + id = 6, + parent = 5, + type = CRSF.TEXT_SELECTION, + name = "Channel", + options = "AUX2;AUX3;AUX4;AUX5;AUX6;AUX7;AUX8;AUX9;AUX10;AUX11;AUX12", + value = 0, + units = "", + }, + { + id = 7, + parent = 5, + type = CRSF.TEXT_SELECTION, + name = "Position", + options = "Disabled;1/Low;2;3;Mid;4;5;6/High", + value = 0, + units = "", + }, + + -- Output Mapping folder + { id = 8, parent = 0, type = CRSF.FOLDER, name = "Output Mapping" }, + { id = 9, parent = 8, type = CRSF.UINT8, name = "Output Ch", value = 1, min = 1, max = 4, units = "" }, + { id = 10, parent = 8, type = CRSF.UINT8, name = "Input Ch", value = 1, min = 1, max = 16, units = "" }, + { + id = 11, + parent = 8, + type = CRSF.TEXT_SELECTION, + name = "Output Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz;10kHzDuty;On/Off;DShot", + value = 0, + units = "", + }, + { + id = 12, + parent = 8, + type = CRSF.TEXT_SELECTION, + name = "Invert", + options = "Off;On", + value = 0, + units = "", + }, + + -- PWM Channel 1 subfolder (nested inside Output Mapping) + { id = 13, parent = 8, type = CRSF.FOLDER, name = "PWM Ch1" }, + { + id = 14, + parent = 13, + type = CRSF.UINT8, + name = "Failsafe", + value = 0, + min = 0, + max = 100, + units = "%", + }, + { + id = 15, + parent = 13, + type = CRSF.TEXT_SELECTION, + name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", + value = 0, + units = "", + }, + + -- PWM Channel 2 subfolder (nested inside Output Mapping) + { id = 16, parent = 8, type = CRSF.FOLDER, name = "PWM Ch2" }, + { + id = 17, + parent = 16, + type = CRSF.UINT8, + name = "Failsafe", + value = 0, + min = 0, + max = 100, + units = "%", + }, + { + id = 18, + parent = 16, + type = CRSF.TEXT_SELECTION, + name = "Mode", + options = "50Hz;60Hz;100Hz;160Hz;333Hz;400Hz", + value = 0, + units = "", + }, + + -- Bind Storage & Bind Mode + { + id = 19, + parent = 0, + type = CRSF.TEXT_SELECTION, + name = "Bind Storage", + options = "Persistent;Volatile;Returnable;Administered", + value = 0, + units = "", + }, + { + id = 20, + parent = 0, + type = CRSF.COMMAND, + name = "Enter Bind Mode", + status = CRSF.CMD_IDLE, + timeout = 50, + info = "", + }, + + -- Model Id + { id = 21, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" }, + + -- Info fields + { id = 22, parent = 0, type = CRSF.INFO, name = "RX Version", value = "3.5.0 825ed8" }, + }, +} + +-- ============================================================================ +-- Parameter lookup helper +-- ============================================================================ + +local function findParam(device, fieldId) + for _, p in ipairs(device.params) do + if p.id == fieldId then + return p + end + end + return nil +end + +local function findDeviceByAddr(addr) + if addr == txDevice.id then + return txDevice + end + if rxDevice and addr == rxDevice.id then + return rxDevice + end + return nil +end + +--- Extract the Nth label (0-indexed) from a semicolon-separated options string. +-- Matches the firmware's findSelectionLabel() behavior. +-- @param options semicolon-separated string (e.g. "10;25;50;100;250") +-- @param index 0-based index +-- @return label string, or "" if index is out of range +local function getOptionLabel(options, index) + local i = 0 + for label in string.gmatch(options, "([^;]+)") do + if i == index then + return label + end + i = i + 1 + end + return "" +end + +-- ============================================================================ +-- Dynamic folder names (mirrors TXModuleParameters.cpp updateFolderNames) +-- ============================================================================ + +--- Update the dynName field on TX Power and VTX Administrator folders +-- so the simulator matches real firmware behavior where folder names show +-- a summary of the current settings in parentheses. +-- @param device the device table whose params to update +local function updateFolderNames(device) + -- TX Power folder (id=6): children Max Power (id=7), Dynamic (id=8) + local txPwrFolder = findParam(device, 6) + local maxPower = findParam(device, 7) + local dynamic = findParam(device, 8) + if txPwrFolder and maxPower then + local pwrLabel = getOptionLabel(maxPower.options, maxPower.value or 0) + local name = "TX Power (" .. pwrLabel + if dynamic and (dynamic.value or 0) > 0 then + local dynLabel = getOptionLabel(dynamic.options, dynamic.value) + name = name .. " " .. dynLabel + end + name = name .. ")" + txPwrFolder.dynName = name + end + + -- VTX Administrator folder (id=10): children Band (id=11), Channel (id=12), + -- Pwr Lvl (id=13), Pitmode (id=14) + local vtxFolder = findParam(device, 10) + local vtxBand = findParam(device, 11) + local vtxChan = findParam(device, 12) + local vtxPwr = findParam(device, 13) + local vtxPit = findParam(device, 14) + if vtxFolder and vtxBand then + local bandVal = vtxBand.value or 0 + if bandVal == 0 then + -- Band is "Off" -> use static name (no dynamic suffix) + vtxFolder.dynName = nil + else + local bandLabel = getOptionLabel(vtxBand.options, bandVal) + local chanLabel = tostring((vtxChan and vtxChan.value) or 1) + local name = "VTX Admin (" .. bandLabel .. ":" .. chanLabel + + local pwrVal = (vtxPwr and vtxPwr.value) or 0 + if pwrVal > 0 then + ---@diagnostic disable-next-line: need-check-nil + local pwrLabel = getOptionLabel(vtxPwr.options, pwrVal) + name = name .. ":" .. pwrLabel + + local pitVal = (vtxPit and vtxPit.value) or 0 + if pitVal == 1 then + name = name .. ":P" + elseif pitVal > 1 then + ---@diagnostic disable-next-line: need-check-nil + local pitLabel = getOptionLabel(vtxPit.options, pitVal) + name = name .. ":" .. pitLabel + end + end + + name = name .. ")" + vtxFolder.dynName = name + end + end +end + +-- ============================================================================ +-- Dynamic telemetry bandwidth (mirrors TXModuleParameters.cpp updateTlmBandwidth) +-- ============================================================================ + +--- Convert a TLM ratio option index to its divisor value. +-- Matches firmware TLMratioEnumToValue(). +-- Options: 0=Std, 1=Off, 2=1:128, 3=1:64, 4=1:32, 5=1:16, 6=1:8, 7=1:4, 8=1:2, 9=Race +-- @param enumval option index (0-based) +-- @return divisor integer (e.g. 128, 64, 32, …) +local function tlmRatioEnumToValue(enumval) + if enumval <= 1 then + return 1 + end -- Std/Off -> 1 (caller handles display) + if enumval >= 9 then + return 1 + end -- Race -> same as Std + -- 2=1:128 -> 128, 3=1:64 -> 64, … 8=1:2 -> 2 + -- Formula: 2^(8 + 1 - enumval) (matching firmware: 1 << (8 + TLM_RATIO_NO_TLM - enumval)) + return math.floor(2 ^ (9 - enumval)) +end + +--- Compute TLM burst max for a given rate and ratio divisor. +-- Matches firmware TLMBurstMaxForRateRatio(). +-- @param rateHz packet rate in Hz +-- @param ratioDiv ratio divisor (e.g. 128, 64, …) +-- @return burst count (>= 1) +local function tlmBurstMaxForRateRatio(rateHz, ratioDiv) + local retVal = math.floor(512 * rateHz / ratioDiv / 1000) + if retVal > 1 then + retVal = retVal - 1 + else + retVal = 1 + end + return retVal +end + +--- Update the Telem Ratio units field to show bandwidth or default ratio. +-- Mirrors firmware updateTlmBandwidth() from TXModuleParameters.cpp. +-- @param device the device table (txDevice) +local function updateTlmBandwidth(device) + local packetRate = findParam(device, 1) -- Packet Rate + local telemRatio = findParam(device, 2) -- Telem Ratio + local switchMode = findParam(device, 3) -- Switch Mode + if not packetRate or not telemRatio then + return + end + + local rateIdx = packetRate.value or 0 + local rateCfg = rateConfigs[rateIdx] + if not rateCfg then + return + end + + local tlmVal = telemRatio.value or 0 + + -- Std (0) or Race (9): display the rate's default ratio + if tlmVal == 0 or tlmVal == 9 then + local defaultDiv = tlmRatioEnumToValue(rateCfg.defaultTlm) + telemRatio.units = " (1:" .. defaultDiv .. ")" + return + end + + -- Off (1): empty units + if tlmVal == 1 then + telemRatio.units = "" + return + end + + -- Specific ratio (2-8): compute bandwidth in bps + local hz = rateCfg.hz + local ratioDiv = tlmRatioEnumToValue(tlmVal) + local burst = tlmBurstMaxForRateRatio(hz, ratioDiv) + + -- Wide mode (value=1) uses 8ch/fullres OTA -> 10 bytes per call + -- Hybrid mode (value=0) uses 4ch/std OTA -> 5 bytes per call + local isFullRes = switchMode and (switchMode.value or 0) == 1 + local bytesPerCall = isFullRes and 10 or 5 + + local bandwidth = math.floor(bytesPerCall * 8 * burst * hz / ratioDiv / (burst + 1)) + + -- FullRes correction: extra bandwidth from telemetry packed into LinkStats packet + -- sizeof(OTA_LinkStats_s) = 4 bytes + if isFullRes then + bandwidth = bandwidth + 8 * (10 - 4) + end + + telemRatio.units = " (" .. bandwidth .. "bps)" +end + +-- Set initial dynamic folder names based on default parameter values +updateFolderNames(txDevice) +-- Set initial telemetry bandwidth display +updateTlmBandwidth(txDevice) + +-- ============================================================================ +-- Scenario State +-- ============================================================================ + +-- Reconnect scenario timing +local reconnectDelay = 500 -- ~5 seconds (getTime() ticks at 10ms) +local startTime = nil -- set on first mockPush/mockPop call + +-- Dynamic RX availability (replaces static hasRxDevice boolean) +local function isRxAvailable() + if config.scenario == "reconnect" then + if not startTime then + return false + end + return getTime() - startTime >= reconnectDelay + end + return config.scenario ~= "no_telemetry" +end + +-- ELRS Lua flag bits (from TXModuleEndpoint.h): +-- bit 0: LUA_FLAG_CONNECTED +-- bit 1: LUA_FLAG_STATUS1 +-- bit 2: LUA_FLAG_MODEL_MATCH (warning) +-- bit 3: LUA_FLAG_ISARMED (warning) +-- bit 4: LUA_FLAG_WARNING1 +-- bit 5: LUA_FLAG_ERROR_CONNECTED (critical) +-- bit 6: LUA_FLAG_ERROR_BAUDRATE (critical) +local function getElrsFlags() + if config.scenario == "reconnect" then + return isRxAvailable() and 0x01 or 0x00 + elseif config.scenario == "model_mismatch" then + return 0x05 -- connected + model mismatch + elseif config.scenario == "armed" then + return 0x09 -- connected + armed + elseif config.scenario == "normal" or config.scenario == "slow_loading" then + return 0x01 -- connected + else + return 0x00 -- no telemetry + end +end + +local function getElrsFlagsInfo() + if config.scenario == "model_mismatch" then + return "Model Mismatch" + elseif config.scenario == "armed" then + return "[ ! Armed ! ]" + end + return "" +end + +-- ============================================================================ +-- Command state machine (per-parameter) +-- ============================================================================ + +local commandStates = {} -- keyed by "deviceId:paramId" + +local function getCommandKey(deviceId, paramId) + return tostring(deviceId) .. ":" .. tostring(paramId) +end + +-- Number of CMD_QUERY polls a command stays in CMD_EXECUTING before completing. +-- Keep low for snappy simulator testing; real hardware controls its own timing. +local COMMAND_EXECUTE_POLLS = 1 + +local function handleCommandWrite(device, param, newStatus) + local key = getCommandKey(device.id, param.id) + if not commandStates[key] then + commandStates[key] = { status = CRSF.CMD_IDLE, info = "" } + end + local state = commandStates[key] + + if newStatus == CRSF.CMD_CLICK or newStatus == CRSF.CMD_CONFIRMED then + local needsConfirm = param.persistent and config.scenario == "normal" + if newStatus == CRSF.CMD_CLICK and needsConfirm then + -- WiFi/BLE commands ask for confirmation only when connected (scenario "normal") + state.status = CRSF.CMD_ASKCONFIRM + state.info = "Confirm " .. param.name .. "?" + else + -- Go straight to executing (matches real ELRS firmware behavior: + -- most commands skip confirmation and execute immediately) + state.status = CRSF.CMD_EXECUTING + state.info = "Executing..." + if param.persistent then + state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi) + else + state.queriesRemaining = COMMAND_EXECUTE_POLLS + end + end + elseif newStatus == CRSF.CMD_CANCEL then + state.status = CRSF.CMD_IDLE + state.info = "" + elseif newStatus == CRSF.CMD_QUERY then + -- Advance executing commands toward completion. + -- Commands with queriesRemaining = nil run indefinitely until cancelled. + if state.status == CRSF.CMD_EXECUTING and state.queriesRemaining then + state.queriesRemaining = state.queriesRemaining - 1 + if state.queriesRemaining <= 0 then + state.status = CRSF.CMD_IDLE + state.info = "Complete" + end + end + end + + -- Update the param for encoding + param.status = state.status + param.info = state.info +end + +-- ============================================================================ +-- mockPush: Processes commands sent by the Lua script +-- ============================================================================ + +local function mockPush(command, data) + if not startTime then + startTime = getTime() + end + + if command == CRSF.FRAMETYPE_DEVICE_PING then + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + + -- TX module responds immediately (local to handset) + queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, destAddr)) + + -- RX device responds with delay (relayed over air link) + -- Uses deferred delivery so it arrives in the next poll cycle, + -- after the TX DEVICE_INFO has been processed + if isRxAvailable() then + queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, destAddr)) + end + return true + elseif command == CRSF.FRAMETYPE_PARAMETER_READ then + -- Parameter read request: data = { deviceId, handsetId, fieldId, chunk } + local deviceId = data[1] + local fieldId = data[3] + local chunk = data[4] or 0 + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + + local device = findDeviceByAddr(deviceId) + if device then + local param + if fieldId == 0 then + -- Field 0 is the root folder (synthetic, not in params list) + param = { id = 0, parent = 0, type = CRSF.FOLDER, name = device.name, _device = device } + else + param = findParam(device, fieldId) + end + if param then + -- Check if there's a command state override + local key = getCommandKey(device.id, param.id) + if commandStates[key] and bit32.band(param.type, 0x7F) == CRSF.COMMAND then + param.status = commandStates[key].status + param.info = commandStates[key].info + end + -- Set device context for folder child ID encoding + param._device = param._device or device + local entry = encodeParameterEntry(device, param, chunk, destAddr) + param._device = nil -- clean up temporary reference + if config.scenario == "slow_loading" then + -- Delay response to simulate slow OTA field loading + delayedResponseQueue[#delayedResponseQueue + 1] = { + command = CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, + data = entry, + deliverAt = getTime() + SLOW_LOADING_DELAY_TICKS, + } + else + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + end + end + end + return true + elseif command == CRSF.FRAMETYPE_PARAMETER_WRITE then + -- Parameter write: data = { deviceId, handsetId, fieldId, value/status } + local deviceId = data[1] + local fieldId = data[3] + local writeValue = data[4] + + -- Special case: ELRS status request (fieldId == 0) + if fieldId == 0 then + local flags = getElrsFlags() + local flagsInfo = getElrsFlagsInfo() + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + queuePush(CRSF.FRAMETYPE_ELRS_STATUS, encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo)) + return true + end + + local device = findDeviceByAddr(deviceId) + if device then + local param = findParam(device, fieldId) + if param then + local t = bit32.band(param.type, 0x7F) + if t == CRSF.COMMAND then + -- Command: handle state machine + handleCommandWrite(device, param, writeValue) + -- Queue the updated parameter entry as response + local destAddr = data[2] or CRSF.ADDRESS_RADIO_TRANSMITTER + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, encodeParameterEntry(device, param, 0, destAddr)) + else + -- Value write: decode based on field type + if t == CRSF.STRING then + local chars = {} + local i = 4 + while data[i] and data[i] ~= 0 do + chars[#chars + 1] = data[i] + i = i + 1 + end + param.value = (#chars > 0) and string.char(table.unpack(chars)) or "" + elseif t == CRSF.FLOAT then + local v = bit32.lshift(data[4] or 0, 24) + + bit32.lshift(data[5] or 0, 16) + + bit32.lshift(data[6] or 0, 8) + + (data[7] or 0) + if v >= 0x80000000 then + v = v - 0x100000000 + end + param.value = v + elseif t == CRSF.UINT16 or t == CRSF.INT16 then + local v = bit32.lshift(data[4] or 0, 8) + (data[5] or 0) + if t == CRSF.INT16 and v >= 0x8000 then + v = v - 0x10000 + end + param.value = v + else + param.value = writeValue + end + -- Defer folder name and bandwidth updates to the next poll cycle. + -- Real firmware runs updateFolderNames() in the event loop, not + -- in the PARAMETER_WRITE handler. No auto-send of parent folder + -- entry either -- the Lua script must explicitly PARAMETER_READ. + folderNamesReadyAt = getTime() + FOLDER_NAMES_UPDATE_TICKS + folderNamesDevice = device + end + end + end + return true + end + + -- Unknown command - ignore + return true +end + +-- ============================================================================ +-- mockPop: Returns next queued packet or nil +-- ============================================================================ + +local function mockPop() + if not startTime then + startTime = getTime() + end + + -- Promote delayed responses whose delivery time has been reached + local now = getTime() + local i = 1 + while i <= #delayedResponseQueue do + if now >= delayedResponseQueue[i].deliverAt then + local entry = tableRemove(delayedResponseQueue, i) + ---@diagnostic disable-next-line: need-check-nil + queuePush(entry.command, entry.data) + else + i = i + 1 + end + end + + local command, data = queuePop() + + -- Apply deferred folder name updates once enough real time has elapsed. + -- Until then, any PARAMETER_READ for a folder returns the stale dynName. + if folderNamesDevice and getTime() >= folderNamesReadyAt then + updateFolderNames(folderNamesDevice) + updateTlmBandwidth(folderNamesDevice) + folderNamesDevice = nil + end + + return command, data +end + +-- ============================================================================ +-- Module found depends on scenario +-- ============================================================================ + +local moduleFound = (config.scenario ~= "no_module") + +-- ============================================================================ +-- Mock Telemetry Sensor Values +-- Per-scenario base values keyed by EdgeTX sensor ID (as used by getSensorValue()). +-- no_module scenario has no entry -> mockTelemetry returns nil. +-- ============================================================================ + +local txModuleTelemetry = { TPWR = 50 } + +local scenarioTelemetry = { + normal = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + }, + armed = { + TPWR = 250, + RFMD = 7, + ["1RSS"] = -78, + ["2RSS"] = -82, + RQly = 100, + ANT = 0, + RxBt = 14.8, + Curr = 28.5, + FM = "ACRO", + Sats = 14, + GSpd = 42.7, + Alt = 85, + }, + model_mismatch = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -90, + ["2RSS"] = -95, + RQly = 95, + ANT = 1, + RxBt = 15.8, + Curr = 0.5, + }, + reconnect = { + -- Same as normal; only served when isRxAvailable() is true + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + }, + slow_loading = { + -- Same as normal; fields load slowly but telemetry is available + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + }, +} + +-- Jitter ranges for sensors that fluctuate in real life. +-- Sensors not listed (TPWR, RFMD, ANT, FM, Sats) stay static. +local sensorJitter = { + ["1RSS"] = 3, -- +/- 3 dBm + ["2RSS"] = 3, + RQly = 2, -- +/- 2% + RxBt = 0.05, -- +/- 0.05V + Curr = 2.0, -- +/- 2A + GSpd = 3.0, + Alt = 5, +} + +-- Telemetry values are cached and only refreshed once per second to match +-- realistic sensor update rates and avoid excessive CPU in the simulator. +local telemetryCache = {} +local lastTelemetryUpdate = 0 +local TELEMETRY_UPDATE_TICKS = 100 -- 100 ticks = 1 second (getTime() at 10ms/tick) + +local function updateTelemetryCache() + local now = getTime() + if now - lastTelemetryUpdate < TELEMETRY_UPDATE_TICKS then + return + end + lastTelemetryUpdate = now + + if not isRxAvailable() then + -- TX module still reports RFMD/TPWR via link stats even without RX. + -- Only provide these when a module is present (not no_module). + telemetryCache = {} + if moduleFound then + for k, v in pairs(txModuleTelemetry) do + telemetryCache[k] = v + end + end + return + end + local t = scenarioTelemetry[config.scenario] + if not t then + telemetryCache = {} + return + end + + telemetryCache = {} + for sensorId, base in pairs(t) do + local jit = sensorJitter[sensorId] + if jit then + local val = base + (math.random() * 2 - 1) * jit + if jit == math.floor(jit) then + val = math.floor(val + 0.5) + end + telemetryCache[sensorId] = val + else + telemetryCache[sensorId] = base + end + end +end + +--- Return a mock telemetry sensor value for the current scenario. +-- Called at ~10 Hz by the widget via crsf.getSensorValue(). Values are +-- regenerated only once per second; intermediate calls return cached data. +-- Returns nil when disconnected or the sensor is not defined. +local function mockGetSensorValue(sensorId) + updateTelemetryCache() + return telemetryCache[sensorId] +end + +-- ============================================================================ +-- Return mock interface +-- ============================================================================ + +return { + pop = mockPop, + push = mockPush, + moduleFound = moduleFound, + getSensorValue = mockGetSensorValue, +} diff --git a/src/SCRIPTS/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua new file mode 100644 index 0000000..32b81ce --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -0,0 +1,422 @@ +--------------------------------------------------------------------------- +-- CRSF Protocol Singleton -- +-- -- +-- Allows multiple widgets to share a single CRSF connection. -- +-- crossfireTelemetryPop() is a destructive queue — each frame can only -- +-- be read once. This singleton is the sole consumer: it drains the -- +-- queue in poll() and fans each frame out to every widget that -- +-- registered a handler for that frame type. Widgets never call -- +-- crossfireTelemetryPop() directly; they register callbacks via -- +-- registerHandler() and push outgoing frames through CRSF.push(). -- +-- -- +-- Loaded once via loadScript() from /SCRIPTS/ELRSLib/crsf.lua. -- +-- Returns a table with protocol constants, handler registry, -- +-- pop queue dispatcher, and device info cache. -- +--------------------------------------------------------------------------- + +local CRSF = {} + +-- ============================================================================ +-- Named protocol constants +-- ============================================================================ + +CRSF.CONST = { + -- Addresses + ADDRESS_TX_MODULE = 0xEE, + ADDRESS_HANDSET = 0xEF, + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses) + FIELD_UINT8 = 0, + FIELD_INT8 = 1, + FIELD_UINT16 = 2, + FIELD_INT16 = 3, + FIELD_FLOAT = 8, + FIELD_TEXT_SELECTION = 9, + FIELD_STRING = 10, + FIELD_FOLDER = 11, + FIELD_INFO = 12, + FIELD_COMMAND = 13, + + -- Command states + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_CONFIRMED = 3, + + -- Folder child list terminator + FIELD_LIST_END = 0xFF, + + -- Module type for model.getModule() check + MODULE_TYPE_CROSSFIRE = 5, +} + +-- ============================================================================ +-- Internal state +-- ============================================================================ + +-- Handler registry: frameType -> { callback1, callback2, ... } +CRSF._handlers = {} + +-- Device info cache (populated by built-in DEVICE_INFO handler) +CRSF.deviceInfo = {} + +-- Telemetry state (populated by built-in ELRS_STATUS handler) +CRSF.hasTelemetry = false +CRSF.modelMismatch = false +CRSF.elrsFlags = 0 +CRSF.elrsFlagsInfo = "" + +-- Tick guard for poll() +CRSF._lastPollTick = 0 + +-- Device info polling +CRSF._lastDevPoll = 0 + +-- ELRS status polling +CRSF._lastStatusPoll = 0 + +-- ============================================================================ +-- Default telemetry wrappers: delegate to real EdgeTX functions +-- When mocking is active, setMock() replaces these with mock implementations +-- ============================================================================ + +function CRSF.pop() + return crossfireTelemetryPop() +end + +function CRSF.push(command, data) + return crossfireTelemetryPush(command, data) +end + +function CRSF.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == CRSF.CONST.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +-- Field ID cache (string sensor name -> numeric ID) +CRSF._vCache = {} + +--- Read a telemetry sensor value by name. +-- Caches the getFieldInfo string->ID lookup; getValue is called every time. +-- setMock() replaces this function with the simulator's mock telemetry. +function CRSF.getSensorValue(id) + local cid = CRSF._vCache[id] + if cid == nil then + local info = getFieldInfo(id) + cid = info and info.id or 0 + CRSF._vCache[id] = cid + end + return cid ~= 0 and getValue(cid) or nil +end + +-- ============================================================================ +-- Simulator integration (mirrors expresslrs.lua setMock pattern) +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + CRSF.pop = mock.pop + CRSF.push = mock.push + CRSF.hasCrsfModule = function() + return mock.moduleFound + end + CRSF.getSensorValue = mock.getSensorValue +end + +setMock() + +-- ============================================================================ +-- Handler registry +-- ============================================================================ + +--- Register a callback for a specific CRSF frame type. +-- @param frameType numeric frame type (use CRSF.CONST.FRAMETYPE_*) +-- @param callback function(data) called when a frame of this type is popped +function CRSF:registerHandler(frameType, callback) + if not self._handlers[frameType] then + self._handlers[frameType] = {} + end + -- Avoid duplicate registration + for _, cb in ipairs(self._handlers[frameType]) do + if cb == callback then + return + end + end + self._handlers[frameType][#self._handlers[frameType] + 1] = callback +end + +--- Remove a previously registered callback. +-- @param frameType numeric frame type +-- @param callback the exact function reference to remove +function CRSF:unregisterHandler(frameType, callback) + local handlers = self._handlers[frameType] + if not handlers then + return + end + for i = #handlers, 1, -1 do + if handlers[i] == callback then + table.remove(handlers, i) + return + end + end +end + +-- ============================================================================ +-- Pop queue dispatcher +-- ============================================================================ + +--- Drain the pop queue and dispatch frames to registered handlers. +-- Guarded by a tick timestamp so only one effective poll runs per tick, +-- even if multiple widgets call this. +function CRSF:poll() + local now = getTime() + if now == self._lastPollTick then + return + end + self._lastPollTick = now + + while true do + local command, data = CRSF.pop() + if command == nil then + break + end + local fh = self._handlers[command] + if fh then + for _, cb in ipairs(fh) do + cb(data) + end + end + end +end + +-- ============================================================================ +-- Shared helpers +-- ============================================================================ + +--- Parse a null-terminated string from a CRSF data array. +-- Modifies data in-place (bytes -> chars) for efficiency. +-- @param data array of byte values +-- @param off 1-based start offset +-- @return string, nextOffset +function CRSF:fieldGetString(data, off) + local startOff = off + while data[off] ~= 0 do + data[off] = string.char(data[off]) + off = off + 1 + end + return table.concat(data, nil, startOff, off - 1), off + 1 +end + +--- Send a DEVICE_PING if device info is not yet available. +-- Rate-limited to at most once per second. +function CRSF:requestDeviceInfo() + if self.deviceInfo.name then + return + end + local now = getTime() + if now - self._lastDevPoll < 100 then + return + end + self._lastDevPoll = now + CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_RADIO_TRANSMITTER }) +end + +--- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0). +-- Updates hasTelemetry via the ELRS_STATUS handler on the next poll(). +-- Rate-limited to at most once per second. +function CRSF:requestElrsStatus() + local now = getTime() + if now - (self._lastStatusPoll or 0) < 100 then + return + end + self._lastStatusPoll = now + CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX_MODULE, CRSF.CONST.ADDRESS_HANDSET, 0, 0 }) +end + +-- ============================================================================ +-- Built-in handlers +-- ============================================================================ + +-- DEVICE_INFO handler: parses and caches module name, version, RFMOD/RFRSSI +local function onDeviceInfo(data) + if data[2] ~= CRSF.CONST.ADDRESS_TX_MODULE then + return + end + + local name, off = CRSF:fieldGetString(data, 3) + local info = CRSF.deviceInfo + info.name = name + -- off points past null terminator of name + -- serNo (4 bytes) + hwVer (4 bytes) + swVer (4 bytes) = 12 bytes + -- swVer is at off+8..off+11, but version fields are at specific offsets: + info.vMaj = data[off + 9] + info.vMin = data[off + 10] + info.vRev = data[off + 11] + info.vStr = string.format("%s (%d.%d.%d)", info.name, info.vMaj, info.vMin, info.vRev) + + -- RFMOD / RFRSSI lookup tables (version-dependent) + if info.vMaj == 4 then + -- selene: allow(mixed_table) + info.RFMOD = { + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "200HzFull", + "250Hz", + "333HzFull", + "500Hz", + "D50", + "K1000Full", + [21] = "25Hz", + [22] = "50Hz", + [23] = "100Hz", + [24] = "100HzFull", + [25] = "150Hz", + [26] = "200Hz", + [27] = "200HzFull", + [28] = "250Hz", + [29] = "333HzFull", + [30] = "500Hz", + [31] = "D250", + [32] = "D500", + [33] = "F500", + [34] = "F1000", + [35] = "DK250", + [36] = "DK500", + [37] = "K1000", + [101] = "X100Full", + [102] = "X150", + } + -- selene: allow(mixed_table) + info.RFRSSI = { + -123, + -120, + -117, + -112, + 0, + -112, + -111, + -111, + 0, + 0, + -112, + -101, + [21] = 0, + [22] = -115, + [23] = 0, + [24] = -112, + [25] = -112, + [26] = 0, + [27] = 0, + [28] = -108, + [29] = -105, + [30] = -105, + [31] = -104, + [32] = -104, + [33] = -104, + [34] = -104, + [35] = -103, + [36] = -103, + [37] = -103, + [101] = -112, + [102] = -112, + } + elseif info.vMaj == 3 then + info.RFMOD = { + "", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "250Hz", + "333HzFull", + "500Hz", + "D250", + "D500", + "F500", + "F1000", + "D50", + "200HzFull", + "DK500", + "K1000", + "9K1000", + "K1000Full", + } + info.RFRSSI = { + 0, + -123, + -115, + -117, + -112, + -112, + -112, + -108, + -105, + -105, + -104, + -104, + -104, + -104, + -112, + -111, + -103, + -103, + 0, + -101, + } + end +end + +-- ELRS_STATUS handler: updates hasTelemetry, modelMismatch, elrsFlagsInfo +local function onElrsStatus(data) + CRSF.elrsFlags = data[6] or 0 + CRSF.hasTelemetry = bit32.btest(CRSF.elrsFlags, 1) + CRSF.modelMismatch = bit32.btest(CRSF.elrsFlags, 4) + + -- Parse null-terminated warning info string starting at data[7] + local parts = {} + local off = 7 + while data[off] and data[off] ~= 0 do + parts[#parts + 1] = string.char(data[off]) + off = off + 1 + end + CRSF.elrsFlagsInfo = table.concat(parts) +end + +-- Register built-in handlers +CRSF:registerHandler(CRSF.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo) +CRSF:registerHandler(CRSF.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus) + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return CRSF diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua new file mode 100644 index 0000000..30dc16c --- /dev/null +++ b/src/SCRIPTS/ELRS/shim.lua @@ -0,0 +1,83 @@ +--------------------------------------------------------------------------- +-- B&W Compatibility Layer -- +-- -- +-- Polyfills for standard Lua library functions missing on B&W radios -- +-- (table.concat, table.remove) and shared helpers (sensor value cache). -- +-- -- +-- Lives in /SCRIPTS/ELRS/ alongside crsf.lua so it is available to -- +-- both color widgets and B&W telemetry scripts. -- +-- -- +-- Usage: local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() -- +--------------------------------------------------------------------------- + +local shim = {} + +-- ============================================================================ +-- table.concat polyfill +-- On color LCD radios the table library is available; on B&W it is not. +-- ============================================================================ + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +-- ============================================================================ +-- table.remove polyfill +-- Removes and returns the element at pos (default: last element). +-- Shifts subsequent elements down to close the gap. +-- ============================================================================ + +if table and table.remove then + shim.tableRemove = table.remove +else + shim.tableRemove = function(t, pos) + local n = #t + if n == 0 then + return nil + end + pos = pos or n + local val = t[pos] + for i = pos, n - 1 do + t[i] = t[i + 1] + end + t[n] = nil + return val + end +end + +-- ============================================================================ +-- Cached sensor value reader +-- Wraps getFieldInfo() + getValue() with a string->ID cache so the name +-- lookup is only done once per sensor. Avoids repeated string searches in +-- the source table on every call. +-- ============================================================================ + +local vCache = {} + +function shim.getSensorValue(name) + local cid = vCache[name] + if cid == nil then + local info = getFieldInfo(name) + cid = info and info.id or 0 + vCache[name] = cid + end + return cid ~= 0 and getValue(cid) or nil +end + +return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua new file mode 100644 index 0000000..1892988 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -0,0 +1,209 @@ +-- TNS|ExpressLRS1|TNE +---- ######################################################################### +---- # # +---- # Copyright (C) OpenTX, adapted for ExpressLRS # +---- # # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- # # +---- # Unified tool for BW and color LCD radios (EdgeTX 2.12+) # +---- ######################################################################### + +local VERSION = "r2" +local useLvgl = (lvgl ~= nil) + +-- ============================================================================ +-- Load shared modules +-- ============================================================================ + +local shim = loadScript("/SCRIPTS/TOOLS/ExpressLRS/shim.lua")() +local Protocol = loadScript("/SCRIPTS/TOOLS/ExpressLRS/protocol.lua")(shim) +local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")() + +-- ============================================================================ +-- App Module: Pure business logic (zero UI references) +-- ============================================================================ + +local App = { + crsfModuleChecked = false, + crsfModuleFound = false, + shouldExit = false, +} + +function App.reset() + App.crsfModuleChecked = false + App.crsfModuleFound = false + App.shouldExit = false + Protocol.reset() +end + +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + App.crsfModuleChecked = true + App.crsfModuleFound = Protocol.hasCrsfModule() + return App.crsfModuleFound +end + +-- Returns true if device was set, false if no change needed. +function App.loadDevice(device) + if Protocol.setDevice(device) then + Navigation.reset() + return true + end + return false +end + +-- Returns true if device was switched. +function App.switchDevice(deviceId, viewState) + local device = Protocol.getDevice(deviceId) + if not device then + return false + end + local prevDeviceId = Protocol.deviceId + if Protocol.setDevice(device) then + Navigation.openDevice(device.name, prevDeviceId, viewState) + return true + end + return false +end + +-- Navigate into folder. +function App.enterFolder(folderId, folderName, viewState) + Navigation.openFolder(folderId, folderName, viewState) + Protocol.loadFolderChildren(folderId) +end + +-- Returns navigation entry (or nil). +function App.goBack() + return Navigation.goBack() +end + +-- Reload at root: switch back to TX device or reload fields + ping. +function App.reloadAtRoot() + if Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER then + local txDevice = Protocol.getDevice(Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) + if txDevice then + App.loadDevice(txDevice) + end + else + Protocol.allocateFields() + Protocol.reloadAllFields() + end + Protocol.pingDevices() +end + +-- ============================================================================ +-- Mock data for simulator +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then + return + end + local mockModule = loadScript("/SCRIPTS/CRSFSimulator/csrfsimulator.lua") + if mockModule == nil then + return + end + local mock = mockModule() + Protocol.pop = mock.pop + Protocol.push = mock.push + Protocol.hasCrsfModule = function() + return mock.moduleFound + end +end + +-- ============================================================================ +-- UI loading (deferred to init) +-- ============================================================================ + +local UI + +local function init() + local deps = { + App = App, + Navigation = Navigation, + Protocol = Protocol, + VERSION = VERSION, + } + if useLvgl then + UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua")(deps) + else + UI = loadScript("/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua")(deps) + end + UI.init() + setMock() +end + +-- ============================================================================ +-- Run (shared orchestrator) +-- ============================================================================ + +local function run(event, touchState) + if event == nil then + return 2 + end + + -- UI-specific pre-checks (LVGL: version/availability check; BW: not defined) + if UI.preCheck then + local result = UI.preCheck() + if result ~= nil then + return result + end + end + + if not App.checkCrsfModule() then + UI.handleNoModule() + if App.shouldExit then + return 2 + end + return 0 + end + + local targetDevice, anyNewDevice = Protocol.poll() + Protocol.tick() + + if Protocol.elrsV1Detected then + UI.handleUnsupported() + return 0 + end + + if targetDevice then + if App.loadDevice(targetDevice) then + UI.onDeviceLoaded() + end + end + if anyNewDevice then + UI.onNewDevice() + end + + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + collectgarbage("collect") + UI.invalidate() + if currentFolder == nil and not Protocol.backgroundLoading then + Protocol.startBackgroundLoad() + end + end + UI.folderWasReady = folderReady + + if Protocol.fieldHiddenChanged then + Protocol.fieldHiddenChanged = nil + UI.visibleFields = nil + end + + UI.render(event, touchState) + + if App.shouldExit then + return 2 + end + return 0 +end + +-- ============================================================================ +-- Return +-- ============================================================================ + +return { init = init, run = run, useLvgl = useLvgl } diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua new file mode 100644 index 0000000..25e834b --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -0,0 +1,82 @@ +---- ######################################################################### +---- # Navigation Module: Folder navigation stack and methods # +---- # Zero dependencies on other modules # +---- ######################################################################### + +local Navigation = { + stack = {}, + -- Navigation entry type constants (integers to save RAM vs strings) + TYPE_FOLDER = 0, + TYPE_DEVICE = 1, + -- Synthetic folder IDs + FOLDER_OTHER_DEVICES = -1, +} + +function Navigation.getCurrent() + local top = Navigation.stack[#Navigation.stack] + return top and top.id or nil -- nil if at root (or device root) +end + +function Navigation.isAtRoot() + return #Navigation.stack == 0 +end + +-- Check if the user has navigated into a device (for hiding "Other Devices") +function Navigation.hasDeviceEntry() + for _, entry in ipairs(Navigation.stack) do + if entry.type == Navigation.TYPE_DEVICE then + return true + end + end + return false +end + +-- viewState: optional table of UI state to preserve (e.g. cursor position). +-- Merged into the nav entry so the UI can restore it on goBack(). +function Navigation.openFolder(folderId, folderName, viewState) + local baseName = folderName + if folderName then + baseName = string.match(folderName, "^(.-)%s*%(.*%)$") or folderName + end + local entry = { + type = Navigation.TYPE_FOLDER, + id = folderId, + name = baseName, + } + if viewState then + for k, v in pairs(viewState) do + entry[k] = v + end + end + Navigation.stack[#Navigation.stack + 1] = entry +end + +function Navigation.openDevice(deviceName, prevDeviceId, viewState) + local entry = { + type = Navigation.TYPE_DEVICE, + id = nil, + name = deviceName, + prevDeviceId = prevDeviceId, + } + if viewState then + for k, v in pairs(viewState) do + entry[k] = v + end + end + Navigation.stack[#Navigation.stack + 1] = entry +end + +function Navigation.goBack() + if #Navigation.stack > 0 then + local entry = Navigation.stack[#Navigation.stack] + Navigation.stack[#Navigation.stack] = nil + return entry + end + return nil +end + +function Navigation.reset() + Navigation.stack = {} +end + +return Navigation diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua new file mode 100644 index 0000000..6a7eab2 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/protocol.lua @@ -0,0 +1,807 @@ +---- ######################################################################### +---- # Protocol Module: CRSF constants, parsing, and field operations # +---- # Shared between BW and LVGL UI implementations # +---- ######################################################################### + +local shim = ... + +local Protocol = { + -- EdgeTX module type for CRSF/ELRS + MODULE_TYPE_CROSSFIRE = 5, + + -- CRSF Field Type Constants + CRSF = { + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + UINT32 = 4, + INT32 = 5, + UINT64 = 6, + INT64 = 7, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + -- Internal/extended types (not in official CRSF protocol) + BACK_EXIT = 14, + DEVICE = 15, + DEVICE_FOLDER = 16, + + -- Frame types + FRAMETYPE_DEVICE_PING = 0x28, + FRAMETYPE_DEVICE_INFO = 0x29, + FRAMETYPE_PARAMETER_SETTINGS_ENTRY = 0x2B, + FRAMETYPE_PARAMETER_READ = 0x2C, + FRAMETYPE_PARAMETER_WRITE = 0x2D, + FRAMETYPE_ELRS_STATUS = 0x2E, + + -- Addresses + ADDRESS_BROADCAST = 0x00, + ADDRESS_RADIO_TRANSMITTER = 0xEA, + ADDRESS_CRSF_RECEIVER = 0xEC, + ADDRESS_CRSF_TRANSMITTER = 0xEE, + ADDRESS_ELRS_LUA = 0xEF, + + -- ELRS identification + ELRS_SERIAL_ID = 0x454C5253, + + -- ELRS flags: bits 0-1 are status (connected, status1), + -- bits 2-4 are warnings (model match, armed, warning1), + -- bits 5-7 are critical errors (error connected, error baudrate, critical2) + ELRS_FLAGS_STATUS_MASK = 0x03, -- bits 0-1: status flags only + ELRS_FLAGS_WARNING_THRESHOLD = 0x1F, -- bits 5+: critical error flags + + -- Command steps (sent as last byte in PARAMETER_WRITE for COMMAND fields) + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, + }, + + -- Handlers dispatch table (populated after function definitions) + handlers = {}, + + -- Device identity (used in every CRSF frame) -- defaults to TX module + ELRS Lua + deviceId = 0xEE, -- ADDRESS_CRSF_TRANSMITTER (can't self-ref before table is created) + handsetId = 0xEF, -- ADDRESS_ELRS_LUA + deviceName = nil, + deviceIsELRS_TX = nil, + + -- Fields collection + fields = {}, + fieldsCount = 0, + fieldPopup = nil, + + -- Devices collection + devices = {}, + + -- Status/flags (parsed from ELRS info messages) + elrsFlags = 0, + elrsFlagsInfo = "", + elrsV1Detected = false, + receivedPackets = nil, + lostPackets = nil, + + -- Protocol timing + linkstatTimeout = 100, + pingTimeout = 0, + + -- Communication state + fieldTimeout = 0, + fieldChunk = 0, + fieldData = nil, + loadQueue = {}, + expectChunksRemain = -1, + backgroundLoading = false, + + -- Telemetry transition tracking (for auto-discovery on reconnect) + hadTelemetry = false, +} + +-- ============================================================================ +-- Reset +-- ============================================================================ + +function Protocol.reset() + Protocol.deviceId = Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER + Protocol.handsetId = Protocol.CRSF.ADDRESS_ELRS_LUA + Protocol.deviceName = nil + Protocol.deviceIsELRS_TX = nil + + Protocol.fields = {} + Protocol.fieldsCount = 0 + Protocol.fieldPopup = nil + + Protocol.devices = {} + + Protocol.elrsFlags = 0 + Protocol.elrsFlagsInfo = "" + Protocol.elrsV1Detected = false + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + + Protocol.linkstatTimeout = 100 + Protocol.pingTimeout = 0 + + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + Protocol.expectChunksRemain = -1 + Protocol.backgroundLoading = false + Protocol.hadTelemetry = false +end + +-- ============================================================================ +-- Telemetry wrappers (replaced by setMock in simulator) +-- ============================================================================ + +function Protocol.pop() + return crossfireTelemetryPop() +end + +function Protocol.push(command, data) + return crossfireTelemetryPush(command, data) +end + +function Protocol.pingDevices() + Protocol.push( + Protocol.CRSF.FRAMETYPE_DEVICE_PING, + { Protocol.CRSF.ADDRESS_BROADCAST, Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER } + ) +end + +-- Check if telemetry is being received from the RX (elrsFlags bit 1) +function Protocol.hasTelemetry() + return bit32.btest(Protocol.elrsFlags, 1) +end + +function Protocol.isModelMismatch() + return bit32.btest(Protocol.elrsFlags, 0x04) +end + +function Protocol.hasCriticalError() + return Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD +end + +-- Response timeout for PARAMETER_READ: +-- 0.5s for local TX module, 5s for remote devices relayed over air link. +function Protocol.fieldResponseTimeout() + return Protocol.deviceIsELRS_TX and 50 or 500 +end + +-- Check if a CRSF-compatible module is available +function Protocol.hasCrsfModule() + for modIdx = 0, 1 do + local mod = model.getModule(modIdx) + if mod and (mod.Type == nil or mod.Type == Protocol.MODULE_TYPE_CROSSFIRE) then + return true + end + end + return false +end + +-- Set active device and prepare fields +-- Returns true if device changed, false if no change needed +function Protocol.setDevice(device) + if not device then + return false + end + if Protocol.deviceId == device.id and Protocol.fieldsCount == device.fieldCount then + return false + end + + Protocol.deviceId = device.id + Protocol.elrsFlags = 0 + Protocol.deviceName = device.name + Protocol.fieldsCount = device.fieldCount + Protocol.deviceIsELRS_TX = device.isElrs and device.id == Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or nil + Protocol.handsetId = Protocol.deviceIsELRS_TX and Protocol.CRSF.ADDRESS_ELRS_LUA + or Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER + + Protocol.allocateFields() + Protocol.reloadAllFields() + return true +end + +-- ============================================================================ +-- Field management functions +-- ============================================================================ + +function Protocol.allocateFields() + Protocol.fields = {} + Protocol.fields[0] = {} -- root folder (field 0) + for i = 1, Protocol.fieldsCount do + Protocol.fields[i] = {} + end +end + +-- Check if all children of a folder have been loaded (have names). +-- folderId: the folder's field ID, or nil for root (uses field 0). +function Protocol.isFolderLoaded(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return false + end + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if not child or not child.name or child.nameStale then + return false + end + end + return true +end + +-- Return load progress for a folder's children as (loaded, total). +-- Returns nil if the folder or its children list is unknown yet. +function Protocol.getFolderLoadProgress(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return nil + end + local total = #folder.children + local loaded = 0 + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name and not child.reloading then + loaded = loaded + 1 + end + end + return loaded, total +end + +function Protocol.reloadAllFields() + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue = {} + -- Start by loading only field 0 (root folder). + -- Its response contains child IDs; only root children are auto-queued. + -- Subfolder children are loaded on-demand via loadFolderChildren(). + Protocol.loadQueue[1] = 0 +end + +-- Parameterized: takes folderId instead of accessing Navigation +function Protocol.getFieldsInFolder(folderId) + local folder = Protocol.fields[folderId or 0] + if not folder or not folder.children then + return {} + end + local result = {} + for _, childId in ipairs(folder.children) do + local child = Protocol.fields[childId] + if child and child.name then + result[#result + 1] = child + end + end + return result +end + +function Protocol.getDevice(id) + for _, device in ipairs(Protocol.devices) do + if device.id == id then + return device + end + end +end + +function Protocol.reloadCurField(field) + Protocol.fieldTimeout = 0 + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id +end + +-- Queue unloaded children of a folder for on-demand loading. +function Protocol.loadFolderChildren(folderId) + local folder = Protocol.fields[folderId] + if not folder or not folder.children then + return + end + for i = #folder.children, 1, -1 do + local childId = folder.children[i] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- Queue all unloaded subfolder children for background preloading. +function Protocol.startBackgroundLoad() + Protocol.backgroundLoading = true + for i = 1, #Protocol.fields do + local field = Protocol.fields[i] + if field.type == Protocol.CRSF.FOLDER and field.children then + for j = #field.children, 1, -1 do + local childId = field.children[j] + local child = Protocol.fields[childId] + if child and not child.name then + Protocol.loadQueue[#Protocol.loadQueue + 1] = childId + end + end + end + end + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + end +end + +-- ============================================================================ +-- Field data helpers +-- ============================================================================ + +function Protocol.fieldGetStrOrOpts(data, offset, last, isOpts) + local r = last or (isOpts and {}) + local optParts = {} + local vcnt = 0 + repeat + local b = data[offset] + offset = offset + 1 + + if not last then + if r and (b == 59 or b == 0) then + r[#r + 1] = shim.tableConcat(optParts) + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + elseif b ~= 0 then + -- Translate legacy arrow bytes (0xC0/0xC1) from ELRS firmware + -- to EdgeTX CHAR_UP/CHAR_DOWN glyphs + if b == 192 and CHAR_UP then + optParts[#optParts + 1] = CHAR_UP + elseif b == 193 and CHAR_DOWN then + optParts[#optParts + 1] = CHAR_DOWN + else + optParts[#optParts + 1] = string.char(b) + end + end + end + until b == 0 + + return (r or shim.tableConcat(optParts)), offset, vcnt +end + +function Protocol.fieldGetValue(data, offset, size) + local result = 0 + for i = 0, size - 1 do + result = bit32.lshift(result, 8) + data[offset + i] + end + return result +end + +-- ============================================================================ +-- Field load functions +-- ============================================================================ + +local function fieldUnsignedLoad(field, data, offset, size, unitoffset) + field.value = Protocol.fieldGetValue(data, offset, size) + field.min = Protocol.fieldGetValue(data, offset + size, size) + field.max = Protocol.fieldGetValue(data, offset + 2 * size, size) + local unit = Protocol.fieldGetStrOrOpts(data, offset + (unitoffset or (4 * size)), field.unit) + field.unit = (unit ~= "") and unit or nil + if size ~= 1 then + field.size = size + end +end + +local function fieldUnsignedToSigned(field, size) + local bandval = bit32.lshift(0x80, (size - 1) * 8) + field.value = field.value - bit32.band(field.value, bandval) * 2 + field.min = field.min - bit32.band(field.min, bandval) * 2 + field.max = field.max - bit32.band(field.max, bandval) * 2 +end + +local function fieldSignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedLoad(field, data, offset, size, unitoffset) + fieldUnsignedToSigned(field, size) + field.size = -size +end + +function Protocol.fieldIntLoad(field, data, offset) + local loadFn = (field.type % 2 == 0) and fieldUnsignedLoad or fieldSignedLoad + return loadFn(field, data, offset, math.floor(field.type / 2) + 1) +end + +function Protocol.fieldFloatLoad(field, data, offset) + fieldSignedLoad(field, data, offset, 4, 21) + field.prec = data[offset + 16] + if field.prec > 3 then + field.prec = 3 + end + field.step = Protocol.fieldGetValue(data, offset + 17, 4) + field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" }) + field.prec = 10 ^ field.prec +end + +function Protocol.fieldTextSelLoad(field, data, offset) + local vcnt + local oldValues = field.values + local cached = field.dirty == nil and oldValues + field.values, offset, vcnt = Protocol.fieldGetStrOrOpts(data, offset, cached, true) + if not cached then + field.disabled = (vcnt <= 1) or nil + -- Preserve table identity if contents unchanged (avoids redundant Choice widget updates) + if oldValues and #oldValues == #field.values then + local same = true + for i = 1, #field.values do + if oldValues[i] ~= field.values[i] then + same = false + break + end + end + if same then + field.values = oldValues + end + end + end + field.value = data[offset] + local unit = Protocol.fieldGetStrOrOpts(data, offset + 4) + field.unit = (unit ~= "") and unit or nil + field.dirty = nil +end + +function Protocol.fieldStringLoad(field, data, offset) + field.value, offset = Protocol.fieldGetStrOrOpts(data, offset) + if #data >= offset then + field.maxlen = data[offset] + end +end + +function Protocol.fieldCommandLoad(field, data, offset) + field.status = data[offset] + field.timeout = data[offset + 1] + local info = Protocol.fieldGetStrOrOpts(data, offset + 2) + field.info = (info ~= "") and info or nil + if field.status == Protocol.CRSF.CMD_IDLE then + Protocol.fieldPopup = nil + end +end + +function Protocol.fieldFolderLoad(field, data, offset) + field.children = {} + while data[offset] and data[offset] ~= 0xFF do + field.children[#field.children + 1] = data[offset] + offset = offset + 1 + end +end + +-- ============================================================================ +-- Field save functions +-- ============================================================================ + +function Protocol.fieldIntSave(field) + local value = field.value + local size = field.size or 1 + if size < 0 then + size = -size + if value < 0 then + value = bit32.lshift(0x100, (size - 1) * 8) + value + end + end + + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + for i = size - 1, 0, -1 do + frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256 + end + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + +function Protocol.fieldStringSave(field) + local frame = { Protocol.deviceId, Protocol.handsetId, field.id } + local val = field.value or "" + local maxlen = field.maxlen or 32 + if #val > maxlen then + val = string.sub(val, 1, maxlen) + end + for i = 1, #val do + local b = string.byte(val, i) + if b ~= 0 then + frame[#frame + 1] = b + end + end + frame[#frame + 1] = 0 + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, frame) +end + +-- ============================================================================ +-- Related fields reload (for value changes) +-- ============================================================================ + +function Protocol.reloadParentFolder(field) + if field.parent and Protocol.fields[field.parent] then + Protocol.fields[field.parent].nameStale = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.parent + local minTimeout = getTime() + Protocol.fieldResponseTimeout() + if Protocol.fieldTimeout < minTimeout then + Protocol.fieldTimeout = minTimeout + end + end +end + +function Protocol.reloadRelatedFields(field) + Protocol.reloadParentFolder(field) + + for fieldId = Protocol.fieldsCount, 1, -1 do + local sibling = Protocol.fields[fieldId] + local siblingType = sibling.type or 99 + if + fieldId ~= field.id + and sibling.parent == field.parent + and (siblingType < Protocol.CRSF.FOLDER or siblingType == Protocol.CRSF.INFO) + then + sibling.dirty = true + sibling.reloading = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = fieldId + end + end + + field.dirty = true + field.reloading = true + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.id + Protocol.fieldTimeout = getTime() + 20 + Protocol.linkstatTimeout = Protocol.fieldTimeout + 100 +end + +function Protocol.handleCommandSave(field) + Protocol.reloadCurField(field) + + if field.status ~= nil then + if field.status < Protocol.CRSF.CMD_CONFIRMED then + field.status = Protocol.CRSF.CMD_CLICK + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, field.id, field.status } + ) + Protocol.fieldPopup = field + Protocol.fieldPopup.lastStatus = Protocol.CRSF.CMD_IDLE + Protocol.fieldTimeout = getTime() + field.timeout + end + end +end + +function Protocol.commandConfirm() + if Protocol.fieldPopup then + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CONFIRMED } + ) + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + Protocol.fieldPopup.status = Protocol.CRSF.CMD_CONFIRMED + end +end + +function Protocol.commandCancel() + if Protocol.fieldPopup then + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + ) + Protocol.fieldPopup = nil + end +end + +-- ============================================================================ +-- Handlers dispatch table +-- ============================================================================ + +Protocol.handlers = { + [Protocol.CRSF.UINT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT8 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.INT16 + 1] = { load = Protocol.fieldIntLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.UINT32 + 1] = nil, + [Protocol.CRSF.INT32 + 1] = nil, + [Protocol.CRSF.UINT64 + 1] = nil, + [Protocol.CRSF.INT64 + 1] = nil, + [Protocol.CRSF.FLOAT + 1] = { load = Protocol.fieldFloatLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.TEXT_SELECTION + 1] = { load = Protocol.fieldTextSelLoad, save = Protocol.fieldIntSave }, + [Protocol.CRSF.STRING + 1] = { load = Protocol.fieldStringLoad, save = Protocol.fieldStringSave }, + [Protocol.CRSF.FOLDER + 1] = { load = Protocol.fieldFolderLoad, save = nil }, + [Protocol.CRSF.INFO + 1] = { load = Protocol.fieldStringLoad, save = nil }, + [Protocol.CRSF.COMMAND + 1] = { load = Protocol.fieldCommandLoad, save = Protocol.handleCommandSave }, +} + +-- ============================================================================ +-- CRSF message parsing +-- ============================================================================ + +function Protocol.parseDeviceInfoMessage(data) + local id = data[2] + local newName, offset = Protocol.fieldGetStrOrOpts(data, 3) + local device = Protocol.getDevice(id) + local isNew = (device == nil) + if isNew then + device = { id = id } + Protocol.devices[#Protocol.devices + 1] = device + end + device.name = newName + device.fieldCount = data[offset + 12] + device.isElrs = Protocol.fieldGetValue(data, offset, 4) == Protocol.CRSF.ELRS_SERIAL_ID + return device, isNew +end + +function Protocol.parseParameterInfoMessage(data) + local fieldId = (Protocol.fieldPopup and Protocol.fieldPopup.id) or Protocol.loadQueue[#Protocol.loadQueue] + if data[2] ~= Protocol.deviceId or data[3] ~= fieldId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return false + end + local field = Protocol.fields[fieldId] + local chunksRemain = data[4] + if not field or (Protocol.fieldData and chunksRemain ~= Protocol.expectChunksRemain) then + return false + end + + local offset + if chunksRemain > 0 or Protocol.fieldChunk > 0 then + Protocol.fieldData = Protocol.fieldData or {} + for i = 5, #data do + Protocol.fieldData[#Protocol.fieldData + 1] = data[i] + data[i] = nil + end + offset = 1 + else + Protocol.fieldData = data + offset = 5 + end + + if chunksRemain > 0 then + Protocol.fieldChunk = Protocol.fieldChunk + 1 + Protocol.expectChunksRemain = chunksRemain - 1 + return false + else + Protocol.loadQueue[#Protocol.loadQueue] = nil + + if #Protocol.fieldData > (offset + 2) then + field.id = fieldId + field.parent = (Protocol.fieldData[offset] ~= 0) and Protocol.fieldData[offset] or nil + field.type = bit32.band(Protocol.fieldData[offset + 1], 0x7f) + local wasHidden = field.hidden + field.hidden = bit32.btest(Protocol.fieldData[offset + 1], 0x80) or nil + if field.hidden ~= wasHidden then + Protocol.fieldHiddenChanged = true + end + local cachedName = (not field.nameStale and not field.reloading) and field.name or nil + field.name, offset = Protocol.fieldGetStrOrOpts(Protocol.fieldData, offset + 2, cachedName) + field.nameStale = nil + field.reloading = nil + local handler = Protocol.handlers[field.type + 1] + if handler and handler.load then + handler.load(field, Protocol.fieldData, offset) + end + if field.min == 0 then + field.min = nil + end + if field.max == 0 then + field.max = nil + end + + -- Auto-queue children for root folder (field 0) and during background preloading. + if field.type == Protocol.CRSF.FOLDER and field.children and (fieldId == 0 or Protocol.backgroundLoading) then + for i = #field.children, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = field.children[i] + end + end + end + + Protocol.fieldChunk = 0 + Protocol.fieldData = nil + + return Protocol.deviceId ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER or #Protocol.loadQueue == 0 + end +end + +function Protocol.parseElrsInfoMessage(data) + if data[2] ~= Protocol.deviceId then + Protocol.fieldData = nil + Protocol.fieldChunk = 0 + return + end + + Protocol.lostPackets = data[3] + Protocol.receivedPackets = (data[4] * 256) + data[5] + local newFlags = data[6] + Protocol.elrsFlags = newFlags + Protocol.elrsFlagsInfo = Protocol.fieldGetStrOrOpts(data, 7) +end + +function Protocol.parseElrsV1Message(data) + if (data[1] ~= Protocol.CRSF.ADDRESS_RADIO_TRANSMITTER) or (data[2] ~= Protocol.CRSF.ADDRESS_CRSF_TRANSMITTER) then + return + end + Protocol.elrsV1Detected = true +end + +-- ============================================================================ +-- Main CRSF communication loop +-- ============================================================================ + +function Protocol.poll() + local command, data + local targetDevice = nil + local anyNewDevice = false + + repeat + command, data = Protocol.pop() + if command == Protocol.CRSF.FRAMETYPE_DEVICE_INFO then + local device, isNew = Protocol.parseDeviceInfoMessage(data) + if device.id == Protocol.deviceId then + targetDevice = device + end + if isNew then + anyNewDevice = true + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then + Protocol.parseParameterInfoMessage(data) + if #Protocol.loadQueue > 0 then + Protocol.fieldTimeout = 0 + elseif Protocol.fieldPopup then + Protocol.fieldTimeout = getTime() + Protocol.fieldPopup.timeout + end + elseif command == Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE then + Protocol.parseElrsV1Message(data) + elseif command == Protocol.CRSF.FRAMETYPE_ELRS_STATUS then + Protocol.parseElrsInfoMessage(data) + end + until command == nil + + return targetDevice, anyNewDevice +end + +function Protocol.tick() + -- Ping on telemetry transition (device may have changed) + local hasTelemetry = Protocol.hasTelemetry() + if hasTelemetry and not Protocol.hadTelemetry then + Protocol.pingDevices() + end + Protocol.hadTelemetry = hasTelemetry + + local time = getTime() + -- Periodic ping for initial device discovery + if #Protocol.devices == 0 and time > Protocol.pingTimeout then + Protocol.pingDevices() + Protocol.pingTimeout = time + 100 -- 1s + end + + if Protocol.fieldPopup then + if time > Protocol.fieldTimeout and Protocol.fieldPopup.status ~= Protocol.CRSF.CMD_ASKCONFIRM then + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_QUERY } + ) + Protocol.fieldTimeout = time + Protocol.fieldPopup.timeout + end + elseif time > Protocol.linkstatTimeout then + if Protocol.deviceIsELRS_TX then + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x0, 0x0 }) + else + Protocol.receivedPackets = nil + Protocol.lostPackets = nil + end + Protocol.linkstatTimeout = time + 100 + elseif time > Protocol.fieldTimeout and Protocol.fieldsCount ~= 0 then + if #Protocol.loadQueue > 0 then + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_READ, + { Protocol.deviceId, Protocol.handsetId, Protocol.loadQueue[#Protocol.loadQueue], Protocol.fieldChunk } + ) + Protocol.fieldTimeout = time + Protocol.fieldResponseTimeout() + else + Protocol.backgroundLoading = false + end + end +end + +return Protocol diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua b/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua new file mode 100644 index 0000000..fcd585b --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/shim.lua @@ -0,0 +1,27 @@ +---- ######################################################################### +---- # Compat layer for BW radios missing standard Lua library functions # +---- ######################################################################### + +local shim = {} + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +return shim diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua new file mode 100644 index 0000000..21c09cc --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -0,0 +1,611 @@ +---- ######################################################################### +---- # BW LCD UI: Rendering, input handling, cursor management # +---- # For black & white radios (no LVGL required) # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local Navigation = deps.Navigation +local Protocol = deps.Protocol +local VERSION = deps.VERSION + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + -- Cursor/selection state (owned entirely by this module) + lineIndex = 1, + pageOffset = 0, + edit = nil, + + -- Visible field list (rebuilt on invalidate) + visibleFields = nil, + + -- Layout constants (set in UI.init) + COL1 = 0, + COL2 = 70, + maxLineIndex = 6, + textSize = 8, + textYoffset = 3, + + -- Redraw state + forceRedraw = true, + folderWasReady = false, + + -- Warning flashing + titleShowWarn = nil, + titleShowWarnTimeout = 100, + + -- Warning dismissal (model mismatch) + warningDismissed = false, + warningDismissedAt = nil, + + -- Command popup spinner + commandRunningIndicator = 1, +} + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + else + UI.COL2 = 70 + end + if LCD_H == 96 then + UI.maxLineIndex = 9 + else + UI.maxLineIndex = 6 + end + UI.COL1 = 0 + UI.textYoffset = 3 + UI.textSize = 8 +end + +-- ============================================================================ +-- Interface: invalidate (does NOT reset cursor) +-- ============================================================================ + +function UI.invalidate() + UI.forceRedraw = true + UI.visibleFields = nil +end + +-- ============================================================================ +-- Interface: onDeviceLoaded (resets cursor + invalidates) +-- ============================================================================ + +function UI.onDeviceLoaded() + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() +end + +-- ============================================================================ +-- Interface: onNewDevice +-- ============================================================================ + +function UI.onNewDevice() + UI.invalidate() +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + UI.drawAlert(" No ExpressLRS", { + "Enable a CRSF Internal", + " or External module in", + " Model settings", + " If module is internal", + "also set Internal RF to", + "CRSF in SYS->Hardware", + }) +end + +-- ============================================================================ +-- Interface: handleUnsupported +-- ============================================================================ + +function UI.handleUnsupported() + UI.drawAlert("Unsupported Firmware", { + "ELRS 1.x firmware detected.", + "Please update to 3.x.", + }) +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, _touchState) + -- Warning flashing timer + local time = getTime() + if time > UI.titleShowWarnTimeout then + UI.titleShowWarn = (Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarnTimeout = time + 100 + UI.forceRedraw = true + end + + -- Warning dismissal cooldown (60s before re-showing) + if UI.warningDismissedAt then + if Protocol.elrsFlags <= Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if time - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + elseif UI.warningDismissed and time - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + end + + -- Model mismatch alert (full-screen, blocks normal rendering) + if Protocol.isModelMismatch() and not UI.warningDismissed then + if event == EVT_VIRTUAL_ENTER then + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + UI.forceRedraw = true + return + elseif event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return + end + UI.drawAlert("Model Mismatch", { + "RX connected but", + "Model ID doesn't match.", + "Toggle Model Match", + "to re-sync", + }, { left = "[OK]", right = "[RTN] Change model" }) + return + end + + -- Force redraw during loading to show progress bar + if #Protocol.loadQueue > 0 then + UI.forceRedraw = true + end + + -- Render: command popup or normal page + if Protocol.fieldPopup ~= nil then + UI.drawPopup(event) + elseif event ~= 0 or UI.forceRedraw or UI.edit then + UI.drawPage(event) + UI.forceRedraw = false + end +end + +-- ============================================================================ +-- Alert screen (clear screen + title + body messages) +-- ============================================================================ + +function UI.drawAlert(title, msgs, actions) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (UI.textSize * 2) - 2 + for _, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + UI.textSize + end + if actions then + y = y + UI.textSize + if actions.left then + lcd.drawText(2, y, actions.left, 0) + end + if actions.right then + lcd.drawText(LCD_W - 2, y, actions.right, RIGHT) + end + end +end + +-- ============================================================================ +-- User action handlers (call App for business logic, manage own state) +-- ============================================================================ + +function UI.openFolder(folderId, folderName) + App.enterFolder(folderId, folderName, { li = UI.lineIndex, po = UI.pageOffset }) + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() +end + +function UI.switchDevice(deviceId) + if App.switchDevice(deviceId, { li = UI.lineIndex, po = UI.pageOffset }) then + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() + end +end + +function UI.handleBack() + if Navigation.isAtRoot() then + App.reloadAtRoot() + else + local entry = App.goBack() + if entry then + UI.lineIndex = entry.li or 1 + UI.pageOffset = entry.po or 0 + if entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + end + end + UI.invalidate() +end + +-- ============================================================================ +-- Build visible field list for current navigation state +-- ============================================================================ + +function UI.buildVisibleFields() + local currentFolder = Navigation.getCurrent() + local vf = {} + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + vf[#vf + 1] = { id = device.id, name = device.name, type = Protocol.CRSF.DEVICE } + end + end + else + local fields = Protocol.getFieldsInFolder(currentFolder) + for _, field in ipairs(fields) do + if not field.hidden then + vf[#vf + 1] = field + end + end + + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + vf[#vf + 1] = { name = "Other Devices", type = Protocol.CRSF.DEVICE_FOLDER } + end + end + + UI.visibleFields = vf +end + +function UI.getField(line) + if not UI.visibleFields then + UI.buildVisibleFields() + end + return UI.visibleFields[line] +end + +function UI.getFieldCount() + if not UI.visibleFields then + UI.buildVisibleFields() + end + return #UI.visibleFields +end + +function UI.getSelectableCount() + return UI.getFieldCount() + 1 +end + +function UI.isOnBackExit() + return UI.lineIndex > UI.getFieldCount() +end + +function UI.getBackExitLabel() + if Navigation.isAtRoot() then + return "-- EXIT (" .. VERSION .. ") --" + else + return "----BACK----" + end +end + +-- ============================================================================ +-- Field value increment +-- ============================================================================ + +function UI.incrField(step) + local field = UI.getField(UI.lineIndex) + if not field then + return + end + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until newval == min or newval == max +end + +-- ============================================================================ +-- Field selection navigation +-- ============================================================================ + +function UI.selectField(step) + local count = UI.getSelectableCount() + if count == 0 then + return + end + local fieldCount = UI.getFieldCount() + local newLineIndex = UI.lineIndex + repeat + newLineIndex = newLineIndex + step + if newLineIndex <= 0 then + newLineIndex = count + elseif newLineIndex > count then + newLineIndex = 1 + UI.pageOffset = 0 + end + if newLineIndex > fieldCount then + break + end + local field = UI.getField(newLineIndex) + if field and field.name then + break + end + until newLineIndex == UI.lineIndex + UI.lineIndex = newLineIndex + if UI.lineIndex > UI.maxLineIndex + UI.pageOffset then + UI.pageOffset = UI.lineIndex - UI.maxLineIndex + elseif UI.lineIndex <= UI.pageOffset then + UI.pageOffset = UI.lineIndex - 1 + end +end + +-- ============================================================================ +-- BW field display functions +-- ============================================================================ + +local function fieldIntDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value .. (field.unit or ""), attr) +end + +local function fieldFloatDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, string.format(field.fmt, field.value / field.prec) .. (field.unit or ""), attr) +end + +local function fieldTextSelDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, (field.values[field.value + 1] or "ERR") .. (field.unit or ""), attr) +end + +local function fieldStringDisplay(field, y, attr) + lcd.drawText(UI.COL2, y, field.value or "", attr) +end + +local function fieldFolderDisplay(field, y, attr) + lcd.drawText(UI.COL1, y, "> " .. field.name, attr + BOLD) +end + +local function fieldCommandDisplay(field, y, attr) + lcd.drawText(10, y, "[" .. field.name .. "]", attr + BOLD) +end + +local displayHandlers = {} +displayHandlers[Protocol.CRSF.UINT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT8] = fieldIntDisplay +displayHandlers[Protocol.CRSF.UINT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.INT16] = fieldIntDisplay +displayHandlers[Protocol.CRSF.FLOAT] = fieldFloatDisplay +displayHandlers[Protocol.CRSF.TEXT_SELECTION] = fieldTextSelDisplay +displayHandlers[Protocol.CRSF.STRING] = fieldStringDisplay +displayHandlers[Protocol.CRSF.INFO] = fieldStringDisplay +displayHandlers[Protocol.CRSF.FOLDER] = fieldFolderDisplay +displayHandlers[Protocol.CRSF.COMMAND] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE] = fieldCommandDisplay +displayHandlers[Protocol.CRSF.DEVICE_FOLDER] = fieldFolderDisplay + +-- ============================================================================ +-- Title bar drawing +-- ============================================================================ + +function UI.drawTitle() + local barHeight = 9 + local goodBadPkt = "" + if Protocol.receivedPackets then + local state = Protocol.hasTelemetry() and "C" or "-" + goodBadPkt = string.format("%u/%u %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if not UI.titleShowWarn then + lcd.drawText(LCD_W - 1, 1, goodBadPkt, RIGHT) + lcd.drawLine(LCD_W - 10, 0, LCD_W - 10, barHeight - 1, SOLID, INVERS) + end + + if loaded and total and total > 0 and loaded < total then + lcd.drawFilledRectangle(UI.COL2, 0, LCD_W, barHeight, GREY_DEFAULT) + lcd.drawGauge(0, 0, UI.COL2, barHeight, loaded, total, 0) + else + lcd.drawFilledRectangle(0, 0, LCD_W, barHeight, GREY_DEFAULT) + if UI.titleShowWarn then + lcd.drawText(UI.COL1, 1, Protocol.elrsFlagsInfo, INVERS) + else + lcd.drawText(UI.COL1, 1, Protocol.deviceName or "Searching...", INVERS) + end + end +end + +-- ============================================================================ +-- Warning display +-- ============================================================================ + +function UI.drawWarning() + lcd.drawText(UI.COL1, UI.textSize * 2, "Error:") + lcd.drawText(UI.COL1, UI.textSize * 3, Protocol.elrsFlagsInfo) + lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER) +end + +-- ============================================================================ +-- Event handling +-- ============================================================================ + +function UI.handleEvent(event) + if UI.getSelectableCount() == 0 then + return + end + + if event == EVT_VIRTUAL_EXIT then + if UI.edit then + UI.edit = nil + local field = UI.getField(UI.lineIndex) + if field and field.id then + Protocol.reloadCurField(field) + end + else + UI.handleBack() + end + elseif event == EVT_VIRTUAL_ENTER then + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + Protocol.elrsFlags = 0 + Protocol.push(Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, { Protocol.deviceId, Protocol.handsetId, 0x2E, 0x00 }) + elseif UI.isOnBackExit() then + if Navigation.isAtRoot() then + App.shouldExit = true + else + UI.handleBack() + end + else + local field = UI.getField(UI.lineIndex) + if field and field.name then + local ft = field.type + + if ft == Protocol.CRSF.FOLDER then + UI.openFolder(field.id, field.name) + elseif ft == Protocol.CRSF.DEVICE_FOLDER then + UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + elseif ft == Protocol.CRSF.DEVICE then + UI.switchDevice(field.id) + elseif ft == Protocol.CRSF.COMMAND then + Protocol.handleCommandSave(field) + elseif not field.disabled and ft <= Protocol.CRSF.TEXT_SELECTION then + UI.edit = not UI.edit + if not UI.edit then + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end + end + end + end + elseif UI.edit then + if event == EVT_VIRTUAL_NEXT then + UI.incrField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.incrField(-1) + end + else + if event == EVT_VIRTUAL_NEXT then + UI.selectField(1) + elseif event == EVT_VIRTUAL_PREV then + UI.selectField(-1) + end + end +end + +-- ============================================================================ +-- Main page rendering +-- ============================================================================ + +function UI.drawPage(event) + UI.handleEvent(event) + + lcd.clear() + UI.drawTitle() + + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_WARNING_THRESHOLD then + UI.drawWarning() + else + local totalCount = UI.getSelectableCount() + for y = 1, UI.maxLineIndex + 1 do + local idx = UI.pageOffset + y + if idx > totalCount then + break + end + local yPos = y * UI.textSize + UI.textYoffset + local isSelected = (UI.lineIndex == idx) + local attr = isSelected and ((UI.edit and BLINK or 0) + INVERS) or 0 + + if idx > UI.getFieldCount() then + lcd.drawText(10, yPos, "[" .. UI.getBackExitLabel() .. "]", attr + BOLD) + else + local field = UI.getField(idx) + if field and field.name then + local ft = field.type + if ft < Protocol.CRSF.FOLDER or ft == Protocol.CRSF.INFO then + lcd.drawText(UI.COL1, yPos, field.name, 0) + end + local displayFn = displayHandlers[ft] + if displayFn then + displayFn(field, yPos, attr) + end + end + end + end + end +end + +-- ============================================================================ +-- Command popup rendering +-- ============================================================================ + +function UI.drawPopup(event) + if event == EVT_VIRTUAL_EXIT then + Protocol.push( + Protocol.CRSF.FRAMETYPE_PARAMETER_WRITE, + { Protocol.deviceId, Protocol.handsetId, Protocol.fieldPopup.id, Protocol.CRSF.CMD_CANCEL } + ) + Protocol.fieldTimeout = getTime() + 200 + end + + if + Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE + then + popupConfirmation(Protocol.fieldPopup.info or "", "Stopped!", event) + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + local result = popupConfirmation(Protocol.fieldPopup.info or "", "PRESS [OK] to confirm", event) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "OK" then + Protocol.commandConfirm() + elseif result == "CANCEL" then + Protocol.fieldPopup = nil + end + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if Protocol.fieldChunk == 0 then + UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 + end + local result = popupConfirmation( + (Protocol.fieldPopup.info or "") + .. " [" + .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) + .. "]", + "Press [RTN] to exit", + event + ) + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + if result == "CANCEL" then + Protocol.commandCancel() + end + end +end + +return UI diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua new file mode 100644 index 0000000..c6549f4 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -0,0 +1,1172 @@ +---- ######################################################################### +---- # LVGL UI: Color LCD rendering, dialogs, command pages # +---- # For color LCD radios with EdgeTX 2.12+ LVGL support # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local Navigation = deps.Navigation +local Protocol = deps.Protocol +local VERSION = deps.VERSION + +local VERSION_CHECK_ENABLED = true + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + currentPage = nil, + uiBuilt = false, + folderWasReady = false, + + -- Warning/command state (LVGL-specific) + warningDismissed = false, + warningDismissedAt = nil, + warningDialog = nil, + commandDialog = nil, +} + +-- ============================================================================ +-- Dialogs Module: Generic LVGL wrappers +-- ============================================================================ + +local Dialogs = {} + +function Dialogs.showConfirm(options) + return lvgl.confirm({ + title = options.title, + message = options.message, + confirm = options.onConfirm, + cancel = options.onCancel, + }) +end + +function Dialogs.showMessage(options) + return lvgl.message({ + title = options.title, + message = options.message, + }) +end + +-- ============================================================================ +-- ModelMismatchDialog +-- ============================================================================ + +local ModelMismatchDialog = {} + +function ModelMismatchDialog.show(onContinue, onExit) + local dg = lvgl.dialog({ + title = "Model Mismatch", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + }) + + dg:build({ + { + type = lvgl.BOX, + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = lvgl.LABEL, text = "Receiver connected but Model ID doesn't match." }, + { type = lvgl.LABEL, text = "RC commands are blocked until resolved." }, + { type = lvgl.LABEL, text = "Toggle the Model Match setting to" }, + { type = lvgl.LABEL, text = "re-sync, or change the EdgeTX model." }, + }, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 48, + text = "Continue", + press = function() + dg:close() + onContinue() + end, + }, + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 48, + text = "Exit to Change Model", + press = function() + dg:close() + onExit() + end, + }, + }, + }, + }) + + return dg +end + +-- ============================================================================ +-- NoModuleDialog +-- ============================================================================ + +local NoModuleDialog = {} + +function NoModuleDialog.show(onExit) + lvgl.clear() + + local dg = lvgl.dialog({ + title = "No Module Found: Check Model Settings", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = onExit, + }) + + dg:build({ + { + type = lvgl.BOX, + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = lvgl.LABEL, text = "- Internal/External module enabled" }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF" }, + { type = lvgl.LABEL, text = "- Minimum Baud rate (depends on packet rate):" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 400k for 250Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 921k for 500Hz" }, + { type = lvgl.LABEL, font = SMLSIZE, text = " 1.87M for F1000" }, + }, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 98, + text = "Exit", + press = function() + dg:close() + onExit() + end, + }, + }, + }, + }) + + return dg +end + +-- ============================================================================ +-- CommandPage: Non-modal pages for command confirm/executing states +-- ============================================================================ + +local CommandPage = {} +local spinnerAngle = 0 + +local function createSpinner(parent) + local r = 20 + local wrapper = parent:box({ + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + color = COLOR_THEME_PRIMARY2, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + }) + wrapper:arc({ + radius = r, + thickness = 4, + rounded = true, + color = COLOR_THEME_PRIMARY1, + startAngle = function() + spinnerAngle = (spinnerAngle + 8) % 360 + return spinnerAngle + end, + endAngle = function() + return spinnerAngle + 120 + end, + }) +end + +function CommandPage.showConfirm(name, info, onConfirm, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = "Send command", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + borderPad = { left = lvgl.PAD_TINY, right = lvgl.PAD_TINY }, + }) + + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = lvgl.LABEL, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + font = BOLD, + text = name or "Command", + }, + { + type = lvgl.LABEL, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = info or "", + }, + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_OUTLINE, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 49, + text = "Confirm", + press = onConfirm, + }, + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 49, + text = "Cancel", + press = onCancel, + }, + }, + }, + }) + + return pg +end + +function CommandPage.showExecuting(title, onCancel) + lvgl.clear() + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = title or "Executing...", + back = onCancel, + }) + + local container = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + align = CENTER, + borderPad = { left = lvgl.PAD_TINY, right = lvgl.PAD_TINY }, + }) + + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + }) + createSpinner(container) + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = lvgl.LABEL, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + color = COLOR_THEME_DISABLED, + text = "Hold [RTN] to exit and keep running", + }, + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_LARGE, + thickness = 0, + }, + { + type = lvgl.BOX, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_OUTLINE, + children = { + { + type = lvgl.BUTTON, + w = lvgl.PERCENT_SIZE + 100, + text = "Cancel command", + press = onCancel, + }, + }, + }, + }) + + return pg +end + +-- ============================================================================ +-- EdgeTX version check +-- ============================================================================ + +local versionCheckResult = nil + +local function checkEdgeTxVersion() + local ver, _radio, maj, minor, rev = getVersion() + + if maj >= 3 then + return true + elseif maj == 2 and minor >= 13 then + return true + elseif maj == 2 and minor == 12 then + local rc = string.match(ver, "%-rc(%d+)") + if rc then + return tonumber(rc) >= 4 + end + return true + end + + return false +end + +local function showVersionRequired() + lvgl.clear() + + local dg = lvgl.dialog({ + title = "EdgeTX Version Not Supported", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() + App.shouldExit = true + end, + }) + + dg:build({ + { + type = "box", + x = 10, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + children = { + { type = "label", text = "Requires EdgeTX:" }, + { type = "label", text = "- 2.12-rc4 or later" }, + { type = "label", text = "- 3.0 or later" }, + }, + }, + { + type = "box", + flexFlow = lvgl.FLOW_ROW, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + children = { + { + type = "button", + text = "Exit", + w = lvgl.PERCENT_SIZE + 98, + press = function() + dg:close() + App.shouldExit = true + end, + }, + }, + }, + }) +end + +local function showLvglRequired() + lcd.clear() + lcd.drawText(5, 10, "LVGL support required", BOLD) + lcd.drawText(5, 20, "Color LCD radio with", 0) + lcd.drawText(5, 30, "EdgeTX 2.12-rc4+ or 3.0+", 0) + lcd.drawText(5, 40, "needed", 0) +end + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if lvgl == nil then + return + end + if VERSION_CHECK_ENABLED then + versionCheckResult = checkEdgeTxVersion() + end +end + +-- ============================================================================ +-- Interface: preCheck (LVGL-specific: version/availability) +-- ============================================================================ + +function UI.preCheck() + if lvgl == nil then + showLvglRequired() + return 0 + end + + if versionCheckResult == false then + if not UI.uiBuilt then + showVersionRequired() + UI.uiBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + return nil +end + +-- ============================================================================ +-- Interface: invalidate +-- ============================================================================ + +function UI.invalidate() + UI.uiBuilt = false +end + +-- ============================================================================ +-- Interface: onDeviceLoaded +-- ============================================================================ + +function UI.onDeviceLoaded() + UI.invalidate() +end + +-- ============================================================================ +-- Interface: onNewDevice +-- ============================================================================ + +function UI.onNewDevice() + if Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES or UI.folderWasReady then + UI.invalidate() + end +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + if not UI.uiBuilt then + NoModuleDialog.show(function() + App.shouldExit = true + end) + UI.uiBuilt = true + end +end + +-- ============================================================================ +-- Interface: handleUnsupported +-- ============================================================================ + +function UI.handleUnsupported() + if not UI.uiBuilt then + Dialogs.showMessage({ + title = "Unsupported Firmware", + message = "ELRS 1.x firmware detected. Please update to 3.x.", + }) + UI.uiBuilt = true + end +end + +-- ============================================================================ +-- User action handlers (call App for business logic) +-- ============================================================================ + +function UI.openFolder(folderId, folderName) + App.enterFolder(folderId, folderName) + UI.invalidate() +end + +function UI.switchDevice(deviceId) + if App.switchDevice(deviceId) then + UI.invalidate() + end +end + +function UI.handleBack() + if Navigation.isAtRoot() then + Dialogs.showConfirm({ + title = "Exit", + message = "Exit ExpressLRS Lua script?", + onConfirm = function() + App.shouldExit = true + end, + }) + else + local entry = App.goBack() + if entry and entry.type == Navigation.TYPE_DEVICE and entry.prevDeviceId then + local prevDevice = Protocol.getDevice(entry.prevDeviceId) + if prevDevice then + Protocol.setDevice(prevDevice) + end + end + UI.invalidate() + end +end + +-- ============================================================================ +-- Command popup handling +-- ============================================================================ + +local function onCommandCancel() + Protocol.commandCancel() + UI.commandDialog = nil + UI.invalidate() +end + +local function handleCommandPopup() + if not Protocol.fieldPopup then + if UI.commandDialog then + UI.commandDialog = nil + UI.invalidate() + end + return + end + + if + Protocol.fieldPopup.status == Protocol.CRSF.CMD_IDLE and Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_IDLE + then + Protocol.reloadAllFields() + Protocol.fieldPopup = nil + UI.commandDialog = nil + UI.invalidate() + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_ASKCONFIRM then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_ASKCONFIRM then + UI.commandDialog = CommandPage.showConfirm(Protocol.fieldPopup.name, Protocol.fieldPopup.info, function() + Protocol.commandConfirm() + end, onCommandCancel) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + elseif Protocol.fieldPopup.status == Protocol.CRSF.CMD_EXECUTING then + if not UI.commandDialog or Protocol.fieldPopup.lastStatus ~= Protocol.CRSF.CMD_EXECUTING then + UI.commandDialog = + CommandPage.showExecuting(Protocol.fieldPopup.name or Protocol.fieldPopup.info, onCommandCancel) + end + Protocol.fieldPopup.lastStatus = Protocol.fieldPopup.status + end +end + +-- ============================================================================ +-- Warning handling +-- ============================================================================ + +local function handleWarning() + if App.shouldExit then + return + end + if Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK then + if not UI.warningDialog and not UI.warningDismissed then + if Protocol.isModelMismatch() then + UI.warningDialog = ModelMismatchDialog.show(function() + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + UI.invalidate() + end, function() + UI.warningDismissed = true + App.shouldExit = true + end) + elseif Protocol.hasCriticalError() then + Dialogs.showMessage({ + title = "Warning", + message = Protocol.elrsFlagsInfo, + }) + UI.warningDialog = true + UI.warningDismissed = true + UI.warningDismissedAt = getTime() + end + end + if UI.warningDismissed and UI.warningDismissedAt then + if getTime() - UI.warningDismissedAt > 6000 then + UI.warningDismissed = false + UI.warningDismissedAt = nil + UI.warningDialog = nil + end + end + else + UI.warningDialog = nil + if not UI.warningDismissedAt or (getTime() - UI.warningDismissedAt > 6000) then + UI.warningDismissed = false + UI.warningDismissedAt = nil + end + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(_event, _touchState) + handleCommandPopup() + + if not UI.commandDialog then + handleWarning() + + local currentFolder = Navigation.getCurrent() + local folderReady = Protocol.isFolderLoaded(currentFolder) + if folderReady and not UI.folderWasReady then + if UI.uiBuilt then + UI.invalidate() + end + end + + if not UI.uiBuilt and #Protocol.fields > 0 then + UI.build() + end + end +end + +-- ============================================================================ +-- Subtitle builder +-- ============================================================================ + +function UI.getSubtitle() + if not Navigation.isAtRoot() then + local top = Navigation.stack[#Navigation.stack] + local subtitleParts = { top.name or "" } + + local loaded, total = Protocol.getFolderLoadProgress(Navigation.getCurrent()) + if loaded and loaded < total then + subtitleParts[#subtitleParts + 1] = string.format(" • Loading %d%%", math.floor(loaded / total * 100)) + end + + return table.concat(subtitleParts) + end + + local loaded, total = Protocol.getFolderLoadProgress(nil) + if loaded and loaded < total and Protocol.fieldsCount > 0 then + return string.format("Loading %d%%", math.floor(loaded / total * 100)) + end + + local subtitle = "" + if Protocol.receivedPackets then + local state = Protocol.hasTelemetry() and "Telemetry OK" or "No telemetry" + subtitle = string.format("%u/%u • %s", Protocol.lostPackets, Protocol.receivedPackets, state) + end + + if + Protocol.elrsFlags > Protocol.CRSF.ELRS_FLAGS_STATUS_MASK + and Protocol.elrsFlagsInfo + and Protocol.elrsFlagsInfo ~= "" + then + if subtitle ~= "" then + subtitle = table.concat({ subtitle, " • ", Protocol.elrsFlagsInfo }) + else + subtitle = Protocol.elrsFlagsInfo + end + end + + return subtitle +end + +-- ============================================================================ +-- Field value increment +-- ============================================================================ + +function UI.incrField(field, step) + local min, max = 0, 0 + if field.type <= Protocol.CRSF.FLOAT then + min = field.min or 0 + max = field.max or 0 + step = (field.step or 1) * step + elseif field.type == Protocol.CRSF.TEXT_SELECTION then + min = 0 + max = #field.values - 1 + end + + local newval = field.value + repeat + newval = newval + step + if newval < min then + newval = min + elseif newval > max then + newval = max + end + + if field.values == nil or #field.values[newval + 1] ~= 0 then + field.value = newval + return + end + until newval == min or newval == max +end + +function UI.isBooleanField(field) + if not field.values or #field.values ~= 2 then + return false + end + return field.values[1] == "Off" and field.values[2] == "On" +end + +-- ============================================================================ +-- Widget creators +-- ============================================================================ + +local IS_NARROW = LCD_W < 400 +local LABEL_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 42 or 50) +local VALUE_PCT = lvgl.PERCENT_SIZE + (IS_NARROW and 58 or 50) + +function UI.createToggleRow(pg, field) + pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + return not field.hidden + end, + children = { + { + type = lvgl.BOX, + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + children = { + { + type = lvgl.TOGGLE, + get = function() + return field.value or 0 + end, + set = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() + return not field.disabled + end, + }, + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = function() + return field.unit or "" + end, + }, + }, + }, + }, + }, + }, + }) +end + +function UI.createChoiceRow(pg, field) + local valuesRef = field.values + local choiceWidget + + local setting = pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + if field.hidden then + return false + end + if field.values ~= valuesRef then + valuesRef = field.values + if choiceWidget then + choiceWidget:set({ values = field.values or {} }) + end + end + return true + end, + }) + + local valueBox = setting:box({ + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + }) + + choiceWidget = valueBox:choice({ + title = field.name, + values = field.values or {}, + filter = function(index) + return (field.values and field.values[index] or "") ~= "" + end, + get = function() + return (field.value or 0) + 1 + end, + set = function(val) + field.value = val - 1 + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + active = function() + return not field.disabled + end, + }) + + valueBox:build({ + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = function() + return field.unit or "" + end, + }, + }, + }, + }) +end + +function UI.createNumberRow(pg, field) + local isFloat = field.type == Protocol.CRSF.FLOAT + local numberEdit = { + type = lvgl.NUMBER_EDIT, + min = field.min or 0, + max = field.max or 255, + get = function() + return field.value or 0 + end, + set = function(val) + field.value = val + end, + edited = function(val) + field.value = val + Protocol.fieldIntSave(field) + Protocol.reloadRelatedFields(field) + end, + display = function(val) + if isFloat then + return string.format(field.fmt or "%.0f", val / (field.prec or 1)) + end + return tostring(val) + end, + active = function() + return not field.disabled + end, + } + + local children + if field.unit then + children = { + { + type = lvgl.BOX, + x = LABEL_PCT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + children = { + numberEdit, + { + type = lvgl.BOX, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_MEDIUM, + text = function() + return field.unit or "" + end, + }, + }, + }, + }, + }, + } + else + numberEdit.x = LABEL_PCT + children = { numberEdit } + end + + pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + return not field.hidden + end, + children = children, + }) +end + +function UI.createInfoRow(pg, field) + pg:build({ + { + type = lvgl.SETTING, + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + return not field.hidden + end, + children = { + { + type = lvgl.LABEL, + x = LABEL_PCT, + text = function() + return field.value or "" + end, + }, + }, + }, + }) +end + +function UI.createStringRow(pg, field) + pg:build({ + { + type = lvgl.SETTING, + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + return not field.hidden + end, + children = { + { + type = lvgl.TEXT_EDIT, + x = LABEL_PCT, + w = VALUE_PCT, + value = field.value or "", + length = math.min(math.max(field.maxlen or 32, 32), 128), + set = function(val) + field.value = val + Protocol.fieldStringSave(field) + Protocol.reloadParentFolder(field) + end, + active = function() + return not field.disabled + end, + }, + }, + }, + }) +end + +function UI.createFolderWidget(pg, field, width) + pg:button({ + text = function() + return field.name or "" + end, + visible = function() + return not field.hidden + end, + w = width or (lvgl.PERCENT_SIZE + 100), + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + UI.openFolder(field.id, field.name) + end, + }) +end + +function UI.createCommandWidget(pg, field) + local wrapper = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = { top = lvgl.PAD_TINY, bottom = lvgl.PAD_TINY }, + visible = function() + return not field.hidden + end, + }) + wrapper:button({ + text = function() + return field.name or "" + end, + w = lvgl.PERCENT_SIZE + 99, + press = function() + Protocol.handleCommandSave(field) + end, + }) +end + +function UI.buildFieldWidget(pg, field, folderWidth) + if not field then + return + end + + local fieldType = field.type + + if fieldType == Protocol.CRSF.FOLDER then + return UI.createFolderWidget(pg, field, folderWidth) + end + + if fieldType == Protocol.CRSF.COMMAND then + return UI.createCommandWidget(pg, field) + end + + if fieldType <= Protocol.CRSF.INT16 or fieldType == Protocol.CRSF.FLOAT then + return UI.createNumberRow(pg, field) + end + + if fieldType == Protocol.CRSF.TEXT_SELECTION then + if UI.isBooleanField(field) then + return UI.createToggleRow(pg, field) + else + return UI.createChoiceRow(pg, field) + end + end + + if fieldType == Protocol.CRSF.STRING then + return UI.createStringRow(pg, field) + end + + if fieldType == Protocol.CRSF.INFO then + return UI.createInfoRow(pg, field) + end +end + +-- ============================================================================ +-- Main build function +-- ============================================================================ + +function UI.build() + lvgl.clear() + + local pageOptions = { + title = "ExpressLRS", + subtitle = UI.getSubtitle, + } + + if not Navigation.isAtRoot() then + pageOptions.backButton = true + pageOptions.back = function() + UI.handleBack() + end + else + pageOptions.back = UI.handleBack + end + + UI.currentPage = lvgl.page(pageOptions) + + local fieldContainer = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_OUTLINE, + }) + + local currentFolder = Navigation.getCurrent() + + if currentFolder == Navigation.FOLDER_OTHER_DEVICES then + local devicesBox = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + borderPad = lvgl.PAD_TINY, + }) + for _, device in ipairs(Protocol.devices) do + if device.id ~= Protocol.deviceId then + devicesBox:button({ + text = device.name or "Unknown", + w = lvgl.PERCENT_SIZE + 100, + press = function() + UI.switchDevice(device.id) + end, + }) + end + end + else + local fieldsInFolder = Protocol.getFieldsInFolder(currentFolder) + local FOLDERS_PER_ROW = 2 + if IS_NARROW then + FOLDERS_PER_ROW = 1 + elseif LCD_W >= 800 then + FOLDERS_PER_ROW = 3 + end + local folderWidth = math.floor(100 / FOLDERS_PER_ROW) - 1 + local i = 1 + while i <= #fieldsInFolder do + local field = fieldsInFolder[i] + + if field.type == Protocol.CRSF.FOLDER then + local folderBatch = {} + while i <= #fieldsInFolder and fieldsInFolder[i].type == Protocol.CRSF.FOLDER do + folderBatch[#folderBatch + 1] = fieldsInFolder[i] + i = i + 1 + end + + if FOLDERS_PER_ROW == 1 then + for j = 1, #folderBatch do + UI.createFolderWidget(fieldContainer, folderBatch[j]) + end + else + for j = 1, #folderBatch, FOLDERS_PER_ROW do + local rowContainer = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + borderPad = lvgl.PAD_OUTLINE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + align = CENTER, + color = COLOR_THEME_PRIMARY2, + }) + + for k = 0, FOLDERS_PER_ROW - 1 do + local folderField = folderBatch[j + k] + if folderField then + UI.createFolderWidget(rowContainer, folderField, lvgl.PERCENT_SIZE + folderWidth) + end + end + end + end + else + UI.buildFieldWidget(fieldContainer, field) + i = i + 1 + end + end + + if currentFolder == nil and Protocol.deviceIsELRS_TX then + UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION }) + end + + if currentFolder == nil then + UI.createInfoRow(fieldContainer, { name = "Device name", value = Protocol.deviceName or "Searching..." }) + end + + if currentFolder == nil and #Protocol.devices > 1 and not Navigation.hasDeviceEntry() then + local wrapper = fieldContainer:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = lvgl.PAD_TINY, + }) + wrapper:button({ + text = "Other Devices", + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.UI_ELEMENT_HEIGHT * 2, + press = function() + UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices") + end, + }) + end + end + + fieldContainer:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }) + + UI.uiBuilt = true +end + +return UI diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua new file mode 100644 index 0000000..eb012b8 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -0,0 +1,607 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Core Logic -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua -- +-- -- +-- Displays ELRS link telemetry using LVGL. Uses the shared CRSF -- +-- singleton passed from main.lua for device info discovery. -- +-- -- +-- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.-- +--------------------------------------------------------------------------- + +local zone, options, crsf = ... + +-- Forward declarations for modules +local Telemetry + +-- ============================================================================ +-- Telemetry Module: cell counting, state, power mapping +-- ============================================================================ + +Telemetry = { + -- Smoothed range percentage + smoothRng = nil, + + -- Cell count detection state + cellCnt = nil, + cellCntCnt = 0, + cellLastV = nil, + + -- Diversity detection + isDiversity = false, + + -- Cached GPS position (persists across disconnects) + ---@type {lat: number, lon: number}|nil + gps = nil, + + -- Power level mapping table + POWERS = { 10, 25, 50, 100, 250, 500, 1000, 2000 }, +} + +--- Map a power value in mW to a 0-based index. +function Telemetry.pwrToIdx(powval) + for k, v in ipairs(Telemetry.POWERS) do + if powval == v then + return k - 1 + end + end + return 7 +end + +--- Cell count detection heuristic (same logic as original). +function Telemetry.checkCellCount(v) + if (Telemetry.cellCntCnt or 0) > 5 then + return + end + + local cellCnt = math.floor(v / 4.35) + 1 + if (v / cellCnt) < 3.0 then + return + end + + if Telemetry.cellCnt ~= cellCnt then + Telemetry.cellCnt = cellCnt + Telemetry.cellCntCnt = 0 + else + if Telemetry.cellLastV == v then + return + end + Telemetry.cellLastV = v + Telemetry.cellCntCnt = Telemetry.cellCntCnt + 1 + end +end + +--- Read all link telemetry values into a table. +function Telemetry.readLink() + return { + tpwr = crsf.getSensorValue("TPWR"), + rfmd = crsf.getSensorValue("RFMD"), + rssi1 = crsf.getSensorValue("1RSS"), + rssi2 = crsf.getSensorValue("2RSS"), + rqly = crsf.getSensorValue("RQly"), + ant = crsf.getSensorValue("ANT"), + } +end + +--- Check if a CRSF/ELRS module is available. +function Telemetry.hasModule() + return crsf.hasCrsfModule() +end + +--- Short status text when not operational or warning active. +--- Returns nil when connected with no warnings. +--- Used by both full-screen and minimized UIs. +function Telemetry.statusText() + if not crsf.hasCrsfModule() then + return "No CRSF module" + end + if not crsf.hasTelemetry then + return "No telemetry" + end + if crsf.modelMismatch then + return "Model Mismatch" + end + return nil +end + +--- Compute smoothed range percentage from RSSI. +function Telemetry.getRangePct(tlm) + local mod = crsf.deviceInfo + local rssi = (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 + if rssi == nil then + return 0 + end + local minrssi = (mod.RFRSSI and tlm.rfmd and mod.RFRSSI[tlm.rfmd + 1]) or -128 + if rssi > -50 then + rssi = -50 + end + local pct = math.floor(100 * (rssi + 50) / (minrssi + 50) + 0.5) + local smooth = Telemetry.smoothRng or pct + if pct > smooth then + pct = smooth + ((pct > smooth + 8) and 4 or 1) + elseif pct < smooth then + pct = smooth - ((pct < smooth - 8) and 4 or 1) + end + Telemetry.smoothRng = pct + return pct +end + +--- Get RF mode string from device info. +function Telemetry.getRfModeStr(rfmd) + if not crsf.hasTelemetry or rfmd == nil then + return "" + end + local mod = crsf.deviceInfo + return (mod.RFMOD and mod.RFMOD[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) +end + +--- Update GPS cache from telemetry. +function Telemetry.updateGps() + local gps = crsf.getSensorValue("GPS") + if gps and gps ~= 0 then + Telemetry.gps = gps + end +end + +--- Update diversity flag from ant value. +function Telemetry.updateDiversity(ant) + if ant and ant ~= 0 then + Telemetry.isDiversity = true + end +end + +--- Pick the active antenna's RSSI value from a readLink() result. +function Telemetry.getRssi(tlm) + if not tlm then + return nil + end + return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 +end + +--- Map range percentage to a warning color. +function Telemetry.rangeColor(pct) + if pct > 90 then + return RED + end + if pct > 70 then + return ORANGE + end + return COLOR_THEME_SECONDARY1 +end + +--- Range percentage + RSSI text (e.g. "Range 69% -90dBm"). +function Telemetry.signalText() + if not crsf.hasTelemetry then + return "" + end + local tlm = Telemetry.readLink() + local pct = Telemetry.getRangePct(tlm) + local parts = { table.concat({ "Range ", tostring(pct), "%" }) } + local rssi = Telemetry.getRssi(tlm) + if rssi then + parts[#parts + 1] = table.concat({ tostring(rssi), "dBm" }) + end + return table.concat(parts, " ") +end + +--- RF mode + TX power text (e.g. "250Hz 50mW"). +function Telemetry.rfDetailText() + local tlm = Telemetry.readLink() + local mode = Telemetry.getRfModeStr(tlm.rfmd) + local parts = { mode } + if crsf.hasTelemetry and tlm.tpwr then + parts[#parts + 1] = table.concat({ tostring(tlm.tpwr), "mW" }) + end + return table.concat(parts, " ") +end + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +-- ============================================================================ +-- Screen detection and UI loading +-- ============================================================================ + +--- Detect screen resolution and return an ID for the per-screen UI file. +local function getScreenId() + local w, h = LCD_W, LCD_H + if w >= 800 then + return "hd" -- 800x480 + elseif w < h then + return "portrait" -- 320x480 (EL18) + elseif w <= 320 then + return "small" -- 320x240 + elseif h >= 320 then + return "sd_tall" -- 480x320 (TX16S) + else + return "sd" -- 480x272 + end +end + +--- Convert Transparency option (0-5) to LVGL opacity (255-0). +local function bgOpacity(opts) + local t = (opts and opts.Transparency) or 2 + return math.max(0, 255 - 51 * t) +end + +local screenId = getScreenId() +local uiPath = table.concat({ "/WIDGETS/ELRSTelemetry/ui/", screenId, ".lua" }) +local WidgetUI = loadScript(uiPath)({ + crsf = crsf, + Telemetry = Telemetry, + bgOpacity = bgOpacity, + WidgetLayout = WidgetLayout, +}) + +-- ============================================================================ +-- Full-screen row helpers (shared across all screen sizes) +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for values. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +local function createDisplayRow(container, label, valueFn, colorFn) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + children = { + { + type = lvgl.LABEL, + text = label, + color = COLOR_THEME_PRIMARY1, + w = lvgl.PERCENT_SIZE + LABEL_PCT, + y = lvgl.PAD_SMALL, + }, + { + type = lvgl.LABEL, + text = valueFn, + color = colorFn or COLOR_THEME_SECONDARY1, + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + y = lvgl.PAD_SMALL, + }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, + }) +end + +-- ============================================================================ +-- Full-screen LVGL layout (shared across all screen sizes) +-- ============================================================================ + +local function buildFullScreen() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = function() + if not Telemetry.hasModule() then + return "No CRSF module" + end + if not crsf.hasTelemetry then + return "No telemetry" + end + if crsf.modelMismatch then + return "Model Mismatch" + end + return "Telemetry" + end, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- No module — show checklist instead of telemetry (matches expresslrs.lua NoModuleDialog) + if not Telemetry.hasModule() then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- Model mismatch warning banner + fields:build({ + { + type = lvgl.LABEL, + font = BOLD, + color = RED, + text = "Model Mismatch — RC commands not sent", + visible = function() + return crsf.modelMismatch + end, + }, + }) + + -- Link Status section + createSectionHeader(fields, "Link Status") + + createDisplayRow(fields, "RF Mode", function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end) + + createDisplayRow(fields, "Link Quality", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + return table.concat({ tostring(tlm.rqly or 0), "%" }) + end) + + createDisplayRow(fields, "RSSI 1", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi1 == nil then + return "--" + end + return table.concat({ tostring(tlm.rssi1), " dBm" }) + end) + + createDisplayRow(fields, "RSSI 2", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.rssi2 == nil then + return "--" + end + return table.concat({ tostring(tlm.rssi2), " dBm" }) + end, function() + if not Telemetry.isDiversity then + return COLOR_THEME_DISABLED + end + return COLOR_THEME_SECONDARY1 + end) + + createDisplayRow(fields, "Active Antenna", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + if not Telemetry.isDiversity then + return "N/A" + end + return (tlm.ant == 1) and "2" or "1" + end) + + createDisplayRow(fields, "Range", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + local pct = Telemetry.getRangePct(tlm) + return table.concat({ tostring(pct), "%" }) + end) + + -- Power section + createSectionHeader(fields, "Power") + + createDisplayRow(fields, "TX Power", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return table.concat({ tostring(tlm.tpwr), " mW" }) + end) + + createDisplayRow(fields, "Power Index", function() + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + if tlm.tpwr == nil then + return "--" + end + return tostring(Telemetry.pwrToIdx(tlm.tpwr)) + end) + + -- Flight Controller section + createSectionHeader(fields, "Flight Controller") + + createDisplayRow(fields, "Battery", function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "--" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat) + end + return string.format("%.2fV", vbat) + end) + + createDisplayRow(fields, "Current", function() + local curr = crsf.getSensorValue("Curr") + if curr == nil or curr <= 0 then + return "--" + end + return string.format("%.2f A", curr) + end) + + createDisplayRow(fields, "Flight Mode", function() + local fm = crsf.getSensorValue("FM") + if fm == nil or fm == 0 then + return "--" + end + return tostring(fm) + end) + + -- GPS section + createSectionHeader(fields, "GPS") + + createDisplayRow(fields, "Satellites", function() + local sats = crsf.getSensorValue("Sats") + if sats == nil then + return "--" + end + return tostring(sats) + end) + + createDisplayRow(fields, "Speed", function() + local gspd = crsf.getSensorValue("GSpd") + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) + + createDisplayRow(fields, "Altitude", function() + local alt = crsf.getSensorValue("Alt") + if alt == nil then + return "--" + end + return tostring(alt) + end) + + createDisplayRow(fields, "Latitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lat) + end) + + createDisplayRow(fields, "Longitude", function() + if Telemetry.gps == nil then + return "--" + end + return tostring(Telemetry.gps.lon) + end) +end + +-- ============================================================================ +-- Widget lifecycle +-- ============================================================================ + +local wgt = { + zone = zone, + options = options, +} + +function wgt.background() + crsf:poll() + crsf:requestDeviceInfo() + crsf:requestElrsStatus() + Telemetry.updateGps() +end + +function wgt.refresh(_event, _touchState) + wgt.background() + + -- Update diversity detection each tick + if crsf.hasTelemetry then + local tlm = Telemetry.readLink() + Telemetry.updateDiversity(tlm.ant) + end +end + +function wgt.update(newOptions) + wgt.options = newOptions + if lvgl.isFullScreen() then + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end +end + +-- Initial build +WidgetUI.build(wgt.zone, wgt.options) + +return wgt diff --git a/src/WIDGETS/ELRSTelemetry/main.lua b/src/WIDGETS/ELRSTelemetry/main.lua new file mode 100644 index 0000000..4223942 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -0,0 +1,46 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget -- +-- Displays ELRS link telemetry: RSSI, LQ, Range, RF Mode, Power, -- +-- Battery, Current, GPS, and Flight Mode. -- +-- -- +-- Uses the loadable.lua pattern to minimize memory when not in use. -- +-- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +--------------------------------------------------------------------------- + +local name = "ELRSTelemetry" + +-- selene: allow(undefined_variable) +local function create(zone, options) + if not _crsfSingleton then + local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") + ---@diagnostic disable-next-line: need-check-nil + _crsfSingleton = getCRSF() + end + local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + ---@diagnostic disable-next-line: need-check-nil + return loadable(zone, options, _crsfSingleton) +end + +local function refresh(widget, event, touchState) + widget.refresh(event, touchState) +end + +local function background(widget) + widget.background() +end + +local function update(widget, options) + widget.update(options) +end + +return { + name = "ExpressLRS Telemetry", + create = create, + refresh = refresh, + background = background, + update = update, + options = { + { "Transparency", VALUE, 2, 0, 5 }, + }, + useLvgl = true, +} diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua new file mode 100644 index 0000000..b1811c9 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -0,0 +1,288 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 800x480 (HD) -- +-- High definition landscape (TX16S Mark 3) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 800x480. +WidgetUI.breakpoints = { + topBarW = 200, + sixthH = 74, + quarterH = 104, + thirdH = 146, +} + +WidgetUI.fonts = { + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = DBLSIZE, detail = 0 }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.hasTelemetry then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, + Telemetry = Telemetry, +}) + +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { + type = lvgl.BOX, + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = lvgl.BOX, + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +--- HD has plenty of room for a title row. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: full telemetry display with title and large fonts. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return 0 + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua new file mode 100644 index 0000000..9b57dc2 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -0,0 +1,258 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x480 portrait. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 55, + quarterH = 78, + thirdH = 110, +} + +WidgetUI.fonts = { + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.hasTelemetry then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, + Telemetry = Telemetry, +}) + +--- 1/6: single line — LQ (bold) + Range/dBm (colored). +--- Portrait is narrower so skip RF mode/power. +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.35) + local c2w = w - c1w + local columns = { + { + type = lvgl.BOX, + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = lvgl.BOX, + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.35) + local rows = { + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.hero, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd.lua b/src/WIDGETS/ELRSTelemetry/ui/sd.lua new file mode 100644 index 0000000..24dfb31 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -0,0 +1,275 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x272 (SD) -- +-- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 37, + quarterH = 52, + thirdH = 73, +} + +WidgetUI.fonts = { + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.hasTelemetry then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, + Telemetry = Telemetry, +}) + +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { + type = lvgl.BOX, + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = lvgl.BOX, + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: hero LQ + Range/RSSI detail. No title on SD. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- No title row on 480x272 — too tight + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.hero, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua new file mode 100644 index 0000000..29feae0 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -0,0 +1,287 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x320 (SD Tall) -- +-- TX16S, TX16S MAX, TX16S Mark II -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x320. +-- 48px taller than 480x272 so widget zones are proportionally taller. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 44, + quarterH = 62, + thirdH = 86, +} + +WidgetUI.fonts = { + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.hasTelemetry then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, + Telemetry = Telemetry, +}) + +--- 1/6: single line — LQ (bold) + Range/dBm (colored) + RF mode/Power (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { + type = lvgl.BOX, + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = lvgl.BOX, + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + hero LQ + Range/RSSI detail. +--- 480x320 has enough room for a title row at 1/3. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- Title row — 480x320 has more vertical room + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return BOLD + end, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = function() + if Telemetry.statusText() then + return BOLD + end + return MIDSIZE + end, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua new file mode 100644 index 0000000..425b325 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -0,0 +1,274 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x240 (Small) -- +-- Small color LCD (PA01) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Telemetry = ctx.Telemetry +local crsf = ctx.crsf +local bgOpacity = ctx.bgOpacity +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x240. +-- Smallest color screen — everything is compact. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 30, + quarterH = 42, + thirdH = 58, +} + +WidgetUI.fonts = { + sixth = { hero = BOLD }, + quarter = { hero = BOLD }, + third = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized display helpers +-- ============================================================================ + +local function heroColorMismatch() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +local function detailColor() + if not crsf.hasTelemetry then + return COLOR_THEME_SECONDARY1 + end + return Telemetry.rangeColor(Telemetry.smoothRng or 0) +end + +local function heroTextLq() + local status = Telemetry.statusText() + if status then + return status + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ + crsf = crsf, + Telemetry = Telemetry, +}) + +--- 1/6: single compact line — LQ (bold) + Range/dBm (colored) + RF mode (neutral). +--- Fixed-width columns prevent layout jumping when digit counts change. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local c2w = math.floor(w * 0.40) + local c3w = w - c1w - c2w + local columns = { + { + type = lvgl.BOX, + w = c1w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + }, + }, + { + type = lvgl.BOX, + w = c2w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = c3w, + h = lvgl.UI_ELEMENT_HEIGHT, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local tlm = Telemetry.readLink() + return Telemetry.getRfModeStr(tlm.rfmd) + end, + }, + }, + }, + } + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: LQ + Range/dBm on row 1, RF mode + Power on row 2. +--- Fixed-width first column prevents layout jumping when digit counts change. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.30) + local rows = { + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = BOLD, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = detailColor, + text = Telemetry.signalText, + }, + }, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + }, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: hero LQ + Range/RSSI detail + RF mode. No title on small screen. +function WidgetUI.buildThird(w, h, opa) + local rows = {} + -- No title row — too tight on 320x240 + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.hero, + color = heroColorMismatch, + text = heroTextLq, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.detail, + color = detailColor, + text = Telemetry.signalText, + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + } + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: full telemetry display with title. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "ExpressLRS", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = function() + return BOLD + end, + color = heroColorMismatch, + text = heroTextLq, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = detailColor, + text = Telemetry.signalText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Telemetry.rfDetailText, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + local vbat = crsf.getSensorValue("RxBt") + if vbat == nil or vbat <= 0 then + return "" + end + Telemetry.checkCellCount(vbat) + local cells = Telemetry.cellCnt + if cells then + return string.format("Bat %dS %.2fV", cells, vbat / cells) + end + return string.format("Bat %.2fV", vbat) + end, + }, + } + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua new file mode 100644 index 0000000..06c9ebc --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -0,0 +1,83 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Shared Top Bar UI -- +-- Used by all screen-specific UI files for the top bar layout. -- +--------------------------------------------------------------------------- + +local ctx = ... +local crsf = ctx.crsf +local Telemetry = ctx.Telemetry + +local TopBarUI = {} + +local function getRssi(tlm) + if not tlm then + return nil + end + return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1 +end + +--- Top bar: two lines stacked, no background. +function TopBarUI.build(w, h) + lvgl.build({ + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = CENTER, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + children = { + { + type = lvgl.LABEL, + align = CENTER, + font = SMLSIZE, + color = function() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY2 + end, + text = function() + if crsf.modelMismatch then + return "Model" + end + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + return table.concat({ "LQ ", tostring(tlm.rqly or 0), "%" }) + end, + }, + { + type = lvgl.LABEL, + align = CENTER, + font = SMLSIZE, + color = function() + if crsf.modelMismatch then + return RED + end + return COLOR_THEME_PRIMARY2 + end, + text = function() + if crsf.modelMismatch then + return "Mismatch" + end + if not crsf.hasTelemetry then + return "--" + end + local tlm = Telemetry.readLink() + local rssi = getRssi(tlm) + if rssi == nil then + return "" + end + return table.concat({ tostring(rssi), "dBm" }) + end, + }, + }, + }, + }) +end + +return TopBarUI diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua new file mode 100644 index 0000000..92829bf --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -0,0 +1,1225 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - Core Logic -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua -- +-- -- +-- Communicates with the ELRS TX module's VTX Administrator via the -- +-- CRSF config protocol (PARAMETER_READ/WRITE). Field IDs are -- +-- discovered at runtime by name -- never hardcoded. -- +-- -- +-- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H. -- +--------------------------------------------------------------------------- + +local zone, options, crsf = ... + +-- Forward declarations for modules (needed for cross-references) +local VTX +local Protocol +local Presets + +-- ============================================================================ +-- VTX Module: Band lookups, field IDs, current/desired state, folder parsing +-- ============================================================================ + +VTX = { + -- Band name lookup tables + -- selene: allow(mixed_table) + BAND_NAMES = { [0] = "Off", "A", "B", "E", "F", "R", "L" }, + BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, + + -- Field IDs (discovered at runtime) + ids = { + folder = nil, + band = nil, + channel = nil, + power = nil, + pitmode = nil, + send = nil, + }, + + -- Current VTX state (parsed from folder name) + state = { + band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L + bandLetter = "?", + channel = 0, + power = 0, + pitmode = false, + }, + + -- Desired VTX state (edited by user in full-screen UI) + desired = { + band = 5, -- Raceband + channel = 1, + power = 0, + pitmode = 0, + }, +} + +--- Parse "VTX Admin (R:4:2:P)" into VTX.state fields. +-- When band is Off, folder name has no dynamic suffix. +function VTX.parseFolderName(name) + local s = VTX.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 + 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 = VTX.BAND_VALUES[parts[1]] or 0 + s.channel = tonumber(parts[2]) or 0 + s.power = tonumber(parts[3]) or 0 + s.pitmode = (parts[#parts] == "P") + return true +end + +--- Sync desired values with current state (e.g. on discovery or entering full-screen). +function VTX.syncDesiredFromState() + local s = VTX.state + local d = VTX.desired + d.band = s.band + d.channel = s.channel + d.power = s.power + d.pitmode = s.pitmode and 1 or 0 +end + +-- ============================================================================ +-- Protocol Module: CRSF config protocol, state machine, discovery, write queue +-- ============================================================================ + +Protocol = { + -- State machine constants + STATE_INIT = 0, + STATE_NO_MODULE = 1, + STATE_DISCOVER_ROOT = 2, + STATE_DISCOVER_CHILDREN = 3, + STATE_DISCOVER_VTX = 4, + STATE_READY = 5, + STATE_SENDING = 6, + + -- Current state + state = 0, -- STATE_INIT + statusText = "Initializing...", + + -- Discovery + loadQueue = {}, + rootChildren = {}, + vtxChildren = {}, + fieldTimeout = 0, + discoveredFields = {}, + + -- Write queue + writeQueue = {}, + writeIdx = 0, + lastWriteTime = 0, + + -- Folder re-read timer + lastFolderPoll = 0, + FOLDER_POLL_INTERVAL = 200, -- 2 seconds +} + +-- State query helpers +function Protocol.isReady() + return Protocol.state == Protocol.STATE_READY +end + +function Protocol.isSending() + return Protocol.state == Protocol.STATE_SENDING +end + +function Protocol.isActive() + return Protocol.state == Protocol.STATE_READY or Protocol.state == Protocol.STATE_SENDING +end + +-- Response timeout for PARAMETER_READ: 0.5s for local TX module. +function Protocol.fieldResponseTimeout() + return 50 +end + +-- ============================================================================ +-- Protocol: Parse helpers +-- ============================================================================ + +--- Parse child IDs from a FOLDER-type PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseChildIds(data) + local ids = {} + local off = 7 + while data[off] ~= nil and data[off] ~= 0 do + off = off + 1 + end + off = off + 1 -- skip null terminator + while data[off] ~= nil and data[off] ~= crsf.CONST.FIELD_LIST_END do + ids[#ids + 1] = data[off] + off = off + 1 + end + return ids +end + +--- Parse field name from a PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseFieldName(data) + local off = 7 + local startOff = off + while data[off] ~= nil and data[off] ~= 0 do + off = off + 1 + end + local chars = {} + for i = startOff, off - 1 do + chars[#chars + 1] = string.char(data[i]) + end + return table.concat(chars) +end + +--- Parse field type from a PARAMETER_SETTINGS_ENTRY payload. +function Protocol.parseFieldType(data) + return bit32.band(data[6] or 0, 0x7F) +end + +-- ============================================================================ +-- Protocol: Sending +-- ============================================================================ + +function Protocol.sendParameterRead(fieldId) + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_READ, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, 0 } + ) +end + +function Protocol.sendParameterWrite(fieldId, value) + crsf.push( + crsf.CONST.FRAMETYPE_PARAMETER_WRITE, + { crsf.CONST.ADDRESS_TX_MODULE, crsf.CONST.ADDRESS_HANDSET, fieldId, value } + ) +end + +--- Request ELRS_STATUS (connection state) by writing field 0. +function Protocol.sendStatusPoll() + Protocol.sendParameterWrite(0, 0) +end + +-- ============================================================================ +-- Protocol: Response handler (registered on singleton dispatcher) +-- ============================================================================ + +function Protocol.onSettingsEntry(data) + if data[2] ~= crsf.CONST.ADDRESS_TX_MODULE then + return + end + + local fieldId = data[3] + local fieldType = Protocol.parseFieldType(data) + local fieldName = Protocol.parseFieldName(data) + + Protocol.discoveredFields[fieldId] = { name = fieldName, type = fieldType } + + -- Pop the field from load queue if it matches + if #Protocol.loadQueue > 0 and Protocol.loadQueue[#Protocol.loadQueue] == fieldId then + Protocol.loadQueue[#Protocol.loadQueue] = nil + Protocol.fieldTimeout = 0 + end + + local st = Protocol.state + + if st == Protocol.STATE_DISCOVER_ROOT then + if fieldId == 0 and fieldType == crsf.CONST.FIELD_FOLDER then + Protocol.rootChildren = Protocol.parseChildIds(data) + for i = #Protocol.rootChildren, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.rootChildren[i] + end + Protocol.state = Protocol.STATE_DISCOVER_CHILDREN + Protocol.statusText = "Discovering fields..." + end + elseif st == Protocol.STATE_DISCOVER_CHILDREN then + if fieldType == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then + VTX.ids.folder = fieldId + Protocol.vtxChildren = Protocol.parseChildIds(data) + VTX.parseFolderName(fieldName) + for i = #Protocol.vtxChildren, 1, -1 do + Protocol.loadQueue[#Protocol.loadQueue + 1] = Protocol.vtxChildren[i] + end + Protocol.state = Protocol.STATE_DISCOVER_VTX + Protocol.statusText = "Loading VTX fields..." + end + if #Protocol.loadQueue == 0 and VTX.ids.folder == nil then + Protocol.statusText = "VTX Admin not found" + end + elseif st == Protocol.STATE_DISCOVER_VTX then + if fieldName == "Band" then + VTX.ids.band = fieldId + elseif fieldName == "Channel" then + VTX.ids.channel = fieldId + elseif fieldName == "Pwr Lvl" then + VTX.ids.power = fieldId + elseif fieldName == "Pitmode" then + VTX.ids.pitmode = fieldId + elseif fieldName == "Send VTx" then + VTX.ids.send = fieldId + end + + if #Protocol.loadQueue == 0 then + if VTX.ids.band and VTX.ids.channel and VTX.ids.power and VTX.ids.pitmode and VTX.ids.send then + Protocol.state = Protocol.STATE_READY + Protocol.statusText = "" + VTX.syncDesiredFromState() + else + Protocol.statusText = "VTX fields incomplete" + end + end + elseif st == Protocol.STATE_READY then + if fieldId == VTX.ids.folder then + VTX.parseFolderName(fieldName) + end + end +end + +-- Register on the shared CRSF singleton +crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, Protocol.onSettingsEntry) + +-- ============================================================================ +-- Protocol: State machine tick +-- ============================================================================ + +function Protocol.tick() + local now = getTime() + local st = Protocol.state + + if st == Protocol.STATE_INIT then + if crsf.hasCrsfModule() then + Protocol.state = Protocol.STATE_DISCOVER_ROOT + Protocol.statusText = "Discovering..." + Protocol.loadQueue = { 0 } + Protocol.fieldTimeout = 0 + else + Protocol.state = Protocol.STATE_NO_MODULE + Protocol.statusText = "No CRSF module" + end + elseif + st == Protocol.STATE_DISCOVER_ROOT + or st == Protocol.STATE_DISCOVER_CHILDREN + or st == Protocol.STATE_DISCOVER_VTX + then + if #Protocol.loadQueue > 0 and now >= Protocol.fieldTimeout then + local fieldId = Protocol.loadQueue[#Protocol.loadQueue] + Protocol.sendParameterRead(fieldId) + Protocol.fieldTimeout = now + Protocol.fieldResponseTimeout() + end + elseif st == Protocol.STATE_READY then + if now - Protocol.lastFolderPoll >= Protocol.FOLDER_POLL_INTERVAL then + Protocol.lastFolderPoll = now + Protocol.sendParameterRead(VTX.ids.folder) + Protocol.sendStatusPoll() + end + elseif st == Protocol.STATE_SENDING then + if Protocol.writeIdx <= #Protocol.writeQueue then + if now - Protocol.lastWriteTime >= 5 then -- 50ms + local entry = Protocol.writeQueue[Protocol.writeIdx] + print(table.concat({ "VTXAdmin: writing field=", entry[1], " val=", entry[2] })) + Protocol.sendParameterWrite(entry[1], entry[2]) + Protocol.lastWriteTime = now + Protocol.writeIdx = Protocol.writeIdx + 1 + end + else + print(table.concat({ "VTXAdmin: write queue complete, ", #Protocol.writeQueue, " entries sent" })) + Protocol.writeQueue = {} + Protocol.writeIdx = 0 + Protocol.state = Protocol.STATE_READY + Protocol.lastFolderPoll = now - Protocol.FOLDER_POLL_INTERVAL + 10 + end + end +end + +-- ============================================================================ +-- Protocol: Write queue builder +-- ============================================================================ + +--- Write changed config fields (band, channel, power, pitmode) to the ELRS module. +--- Does NOT send the "Send VTx" command — call pushToVtx() separately for that. +function Protocol.writeConfig() + if not Protocol.isReady() then + print("VTXAdmin: writeConfig() skipped - not ready") + return + end + + local s = VTX.state + local d = VTX.desired + Protocol.writeQueue = {} + + 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), + })) + + if d.band ~= s.band then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.band, d.band } + end + if d.channel ~= s.channel then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.channel, d.channel } + end + if d.power ~= s.power then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.power, d.power } + end + + local desiredPit = d.pitmode + if type(desiredPit) == "boolean" then + desiredPit = desiredPit and 1 or 0 + end + local currentPit = s.pitmode and 1 or 0 + if desiredPit ~= currentPit then + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.pitmode, desiredPit } + end + + print(table.concat({ "VTXAdmin: write queue built, ", #Protocol.writeQueue, " field(s)" })) + + if #Protocol.writeQueue > 0 then + Protocol.writeIdx = 1 + Protocol.lastWriteTime = 0 + Protocol.state = Protocol.STATE_SENDING + end +end + +--- Append the "Send VTx" command to the write queue, pushing config to the VTX. +function Protocol.pushToVtx() + if not Protocol.isReady() and Protocol.state ~= Protocol.STATE_SENDING then + print("VTXAdmin: pushToVtx() skipped - not ready") + return + end + + print("VTXAdmin: pushToVtx() - queuing Send VTx command") + Protocol.writeQueue[#Protocol.writeQueue + 1] = { VTX.ids.send, crsf.CONST.CMD_CLICK } + + if Protocol.state ~= Protocol.STATE_SENDING then + Protocol.writeIdx = 1 + Protocol.lastWriteTime = 0 + Protocol.state = Protocol.STATE_SENDING + end +end + +-- ============================================================================ +-- Presets Module: 6POS preset storage, file I/O, quick-change processing +-- ============================================================================ + +Presets = { + PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt", + + -- Preset data + items = {}, + enabled = false, + source = 0, -- 6POS source ID (0 = not configured) + autoPushVtx = false, -- auto push to VTX on 6POS change + pushSource = 0, -- source ID for manual "Send VTx" trigger (0 = not configured) + + -- 6POS processing state + lastPos = -1, + stablePos = -1, + stableTime = 0, + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- Push source edge detection state + pushLastVal = -1, +} + +-- ============================================================================ +-- Presets: File I/O (key=value format) +-- ============================================================================ + +--- Parse a "key=value" line using plain string.find (no regex). +--- Returns key, value strings or nil if no '=' found. +local function parseKV(line) + local eq = string.find(line, "=", 1, true) + if not eq then + return nil, nil + end + return string.sub(line, 1, eq - 1), string.sub(line, eq + 1) +end + +--- Split "band,channel" using plain string.find (no regex). +local function splitBandChannel(val) + local comma = string.find(val, ",", 1, true) + if not comma then + return nil, nil + end + return tonumber(string.sub(val, 1, comma - 1)), tonumber(string.sub(val, comma + 1)) +end + +--- File format: key=value lines (one per line). +--- Keys: enabled, source, autoPushVtx, pushSource, p1..p6 (values: "band,channel"). +function Presets.load() + local p = {} + local enabled = false + local source = 0 + local autoPushVtx = false + local pushSource = 0 + local f = io.open(Presets.PATH, "r") + if f then + local data = io.read(f, 512) + io.close(f) + if data and #data > 0 then + -- Split by newlines using plain string.find + local pos = 1 + while pos <= #data do + local nl = string.find(data, "\n", pos, true) + local line + if nl then + line = string.sub(data, pos, nl - 1) + pos = nl + 1 + else + line = string.sub(data, pos) + pos = #data + 1 + end + local key, val = parseKV(line) + if key == "enabled" then + enabled = (val == "1") + elseif key == "source" then + source = tonumber(val) or 0 + elseif key == "autoPushVtx" then + autoPushVtx = (val == "1") + elseif key == "pushSource" then + pushSource = tonumber(val) or 0 + elseif key and val then + -- Check for p1..p6 using plain sub + if string.sub(key, 1, 1) == "p" then + local idx = tonumber(string.sub(key, 2)) + if idx and idx >= 1 and idx <= 6 then + local b, ch = splitBandChannel(val) + if b and ch then + p[idx] = { band = b, channel = ch } + end + end + end + end + end + end + end + -- Fill missing positions with Raceband defaults (R1..R6) + for i = 1, 6 do + if not p[i] then + p[i] = { band = 5, channel = i } + end + end + Presets.items = p + Presets.enabled = enabled + Presets.source = source + Presets.autoPushVtx = autoPushVtx + Presets.pushSource = pushSource + + print(table.concat({ + "VTXAdmin: presets loaded - enabled=", + tostring(enabled), + " source=", + source, + " autoPushVtx=", + tostring(autoPushVtx), + " pushSource=", + pushSource, + })) + for i = 1, 6 do + print(table.concat({ "VTXAdmin: preset ", i, ": band=", p[i].band, " ch=", p[i].channel })) + end +end + +function Presets.save() + print(table.concat({ "VTXAdmin: saving presets to ", Presets.PATH })) + local f = io.open(Presets.PATH, "w") + if f then + io.write(f, table.concat({ "enabled=", Presets.enabled and "1" or "0", "\n" })) + io.write(f, table.concat({ "source=", Presets.source, "\n" })) + io.write(f, table.concat({ "autoPushVtx=", Presets.autoPushVtx and "1" or "0", "\n" })) + io.write(f, table.concat({ "pushSource=", Presets.pushSource, "\n" })) + for i = 1, 6 do + io.write(f, table.concat({ "p", i, "=", Presets.items[i].band, ",", Presets.items[i].channel, "\n" })) + end + io.close(f) + print("VTXAdmin: presets saved OK") + else + print(table.concat({ "VTXAdmin: ERROR - could not open ", Presets.PATH, " for writing" })) + end +end + +-- ============================================================================ +-- Presets: 6POS quick-change processing +-- ============================================================================ + +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 + +--- Called every background tick. Reads the 6POS source, debounces, +--- and triggers a VTX send on edge-detected position changes. +function Presets.process() + if not Presets.enabled then + return + end + if Presets.source == 0 then + return + end + + local value = getValue(Presets.source) + if value == nil then + return + end + + local pos = mapTo6Pos(value) + local now = getTime() + + -- Debounce: require stable position for DEBOUNCE ticks + if pos ~= Presets.stablePos then + Presets.stablePos = pos + Presets.stableTime = now + return + end + if now - Presets.stableTime < Presets.DEBOUNCE then + return + end + + -- Edge-triggered: only send on position change + if pos == Presets.lastPos then + return + end + Presets.lastPos = pos + + local preset = Presets.items[pos] + if preset and preset.band > 0 then + VTX.desired.band = preset.band + VTX.desired.channel = preset.channel + print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel })) + Protocol.writeConfig() + if Presets.autoPushVtx then + Protocol.pushToVtx() + end + else + print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> Off (skipped)" })) + end +end + +--- Called every background tick. Edge-detects the pushSource going high +--- and triggers Protocol.pushToVtx() to send the current config to the VTX. +function Presets.processPushSource() + if Presets.autoPushVtx then + return + end + if Presets.pushSource == 0 then + return + end + + local val = getValue(Presets.pushSource) + if val == nil then + val = -1 + end + local high = val > 0 + + local wasHigh = Presets.pushLastVal > 0 + Presets.pushLastVal = val + + -- Edge detection: trigger only on rising edge (low -> high) + if high and not wasHigh then + print("VTXAdmin: push source triggered - sending VTx command") + Protocol.pushToVtx() + end +end + +-- Initialize presets from file +Presets.load() + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +function WidgetLayout.column(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +function WidgetLayout.row(w, h, opa, children) + lvgl.build({ + { + type = lvgl.RECTANGLE, + x = 0, + y = 0, + w = w, + h = h, + color = COLOR_THEME_PRIMARY2, + opacity = opa, + filled = true, + }, + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = lvgl.PAD_SMALL, + children = children, + }, + }) +end + +-- ============================================================================ +-- VTXDisplay: shared display formatters for minimized UI +-- ============================================================================ + +local VTXDisplay = {} + +--- True when VTX is tuned to a band (band+channel should be shown in fixed column). +function VTXDisplay.showChannel() + return Protocol.isActive() and VTX.state.band > 0 +end + +--- True when a status message should be shown (loading, error, VTX off). +function VTXDisplay.showStatus() + return not Protocol.isActive() or VTX.state.band == 0 +end + +--- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise. +function VTXDisplay.bandChannel() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return table.concat({ VTX.state.bandLetter, VTX.state.channel }) +end + +--- Short status message for non-VTX states, "" when VTX is tuned. +function VTXDisplay.statusText() + if Protocol.state == Protocol.STATE_NO_MODULE then + return "No module" + end + if not Protocol.isActive() then + return "Loading..." + end + if VTX.state.band == 0 then + return "VTX Off" + end + return "" +end + +function VTXDisplay.detailLine() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "" + end + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" + return table.concat({ pwr, pit }) +end + +function VTXDisplay.powerShort() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" +end + +function VTXDisplay.detailLong() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" + local pit = VTX.state.pitmode and " Pit Mode On" or " Pit Mode Off" + return table.concat({ pwr, pit }) +end + +function VTXDisplay.mainColor() + if VTX.state.pitmode then + return RED + end + return COLOR_THEME_PRIMARY1 +end + +function VTXDisplay.build6posLabels() + if Protocol.state == Protocol.STATE_NO_MODULE then + return {} + end + if not Presets.enabled then + return {} + end + local labels = {} + for i = 1, 6 do + local idx = i + labels[#labels + 1] = { + type = lvgl.LABEL, + font = SMLSIZE, + color = function() + return (Presets.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED + end, + text = function() + local p = Presets.items[idx] + local band = VTX.BAND_NAMES[p.band] or "?" + if band == "Off" then + return table.concat({ idx, ":Off" }) + end + return table.concat({ idx, ":", band, p.channel }) + end, + } + end + return labels +end + +function VTXDisplay.buildCheatsheet() + local labels = VTXDisplay.build6posLabels() + if #labels == 0 then + return nil + end + return { + type = lvgl.BOX, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + align = LEFT, + visible = function() + return Protocol.state ~= Protocol.STATE_NO_MODULE + end, + children = labels, + } +end + +-- ============================================================================ +-- Screen detection and UI loading +-- ============================================================================ + +--- Detect screen resolution and return an ID for the per-screen UI file. +local function getScreenId() + local w, h = LCD_W, LCD_H + if w >= 800 then + return "hd" -- 800x480 + elseif w < h then + return "portrait" -- 320x480 (EL18) + elseif w <= 320 then + return "small" -- 320x240 + elseif h >= 320 then + return "sd_tall" -- 480x320 (TX16S) + else + return "sd" -- 480x272 + end +end + +--- Convert Transparency option (0-5) to LVGL opacity (255-0). +local function bgOpacity(opts) + local t = (opts and opts.Transparency) or 2 + return math.max(0, 255 - 51 * t) +end + +local screenId = getScreenId() +local uiPath = table.concat({ "/WIDGETS/ELRSVTXAdmin/ui/", screenId, ".lua" }) +local WidgetUI = loadScript(uiPath)({ + crsf = crsf, + VTX = VTX, + Protocol = Protocol, + Presets = Presets, + bgOpacity = bgOpacity, + VTXDisplay = VTXDisplay, + WidgetLayout = WidgetLayout, +}) + +-- ============================================================================ +-- Full-screen row helpers (shared across all screen sizes) +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for controls. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +local function createRow(container, label, hint) + local row = container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + }) + + local labelChildren = { + { type = lvgl.LABEL, y = lvgl.PAD_SMALL, text = label, color = COLOR_THEME_PRIMARY1 }, + } + if hint then + labelChildren[#labelChildren + 1] = { + type = lvgl.LABEL, + text = hint, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + } + end + + row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + thickness = 0, + flexFlow = hint and lvgl.FLOW_COLUMN or nil, + h = not hint and lvgl.UI_ELEMENT_HEIGHT or nil, + children = labelChildren, + }) + + local ctrl = row:rectangle({ + w = lvgl.PERCENT_SIZE + (100 - LABEL_PCT), + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER, + }) + + return ctrl +end + +local function createChoiceRow(container, label, values, getFn, setFn) + local ctrl = createRow(container, label) + ctrl:choice({ + title = label, + values = values, + get = getFn, + set = setFn, + }) +end + +local function createNumberRow(container, label, min, max, getFn, setFn, editedFn, displayFn) + local ctrl = createRow(container, label) + ctrl:numberEdit({ + min = min, + max = max, + get = getFn, + set = setFn, + edited = editedFn, + display = displayFn, + }) +end + +local function createToggleRow(container, label, getFn, setFn) + local ctrl = createRow(container, label) + ctrl:toggle({ + get = getFn, + set = setFn, + }) +end + +local function createSourceRow(container, label, getFn, setFn, filter, hint) + local ctrl = createRow(container, label, hint) + ctrl:source({ + get = getFn, + set = setFn, + filter = filter, + }) +end + +local function createHintRow(container, text) + container:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + children = { + { + type = lvgl.LABEL, + text = text, + color = COLOR_THEME_DISABLED, + font = SMLSIZE, + w = lvgl.PERCENT_SIZE + 100, + }, + }, + }) +end + +local function createSectionHeader(container, title) + container:build({ + { + type = lvgl.RECTANGLE, + w = lvgl.PERCENT_SIZE + 100, + h = lvgl.PAD_SMALL, + thickness = 0, + }, + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_PRIMARY1, + text = title, + }, + }) +end + +-- ============================================================================ +-- Full-screen LVGL layout (shared across all screen sizes) +-- ============================================================================ + +local function buildFullScreen() + lvgl.clear() + + local d = VTX.desired + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = function() + if Protocol.isActive() then + return "VTX Administrator" + end + return Protocol.statusText + end, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog) + if Protocol.state == Protocol.STATE_NO_MODULE then + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = { + { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 }, + { type = lvgl.LABEL, text = "- Internal/External module enabled", color = COLOR_THEME_DISABLED }, + { type = lvgl.LABEL, text = "- Protocol set to CRSF", color = COLOR_THEME_DISABLED }, + { + type = lvgl.LABEL, + text = "- Baud rate: 400k (250Hz), 921k (500Hz), 1.87M (F1000)", + color = COLOR_THEME_DISABLED, + }, + }, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- VTX Settings section + createSectionHeader(fields, "VTX Settings") + + createChoiceRow(fields, "Band", { "Off", "A", "B", "E", "F", "R", "L" }, function() + return d.band + 1 + end, function(idx) + d.band = idx - 1 + Protocol.writeConfig() + end) + + createNumberRow(fields, "Channel", 1, 8, function() + return d.channel + end, function(v) + d.channel = v + end, function(v) + d.channel = v + Protocol.writeConfig() + end) + + createNumberRow(fields, "Power Level", 0, 8, function() + return d.power + end, function(v) + d.power = v + end, function(v) + d.power = v + Protocol.writeConfig() + end, function(v) + return v == 0 and "-" or tostring(v) + end) + + createToggleRow(fields, "Pit Mode", function() + return d.pitmode + end, function(v) + d.pitmode = v + Protocol.writeConfig() + end) + + local sendWrapper = fields:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + align = CENTER, + borderPad = { top = lvgl.PAD_SMALL, bottom = lvgl.PAD_SMALL }, + }) + sendWrapper:button({ + text = function() + if Protocol.isSending() then + return "Sending..." + end + return "Send VTx" + end, + w = lvgl.PERCENT_SIZE + 99, + press = function() + Protocol.writeConfig() + Protocol.pushToVtx() + end, + active = function() + return Protocol.isReady() + end, + }) + + -- 6POS Quick Change section + createSectionHeader(fields, "6POS Quick Change") + + createToggleRow(fields, "Enabled", function() + return Presets.enabled and 1 or 0 + end, function(v) + Presets.enabled = (v == 1) + Presets.save() + end) + + createSourceRow(fields, "Source", function() + return Presets.source + end, function(v) + Presets.source = v or 0 + Presets.save() + end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH) + + createToggleRow(fields, "Auto Push to VTX", function() + return Presets.autoPushVtx and 1 or 0 + end, function(v) + Presets.autoPushVtx = (v == 1) + Presets.save() + end) + + createSourceRow( + fields, + "Send VTx Trigger", + function() + return Presets.pushSource + end, + function(v) + Presets.pushSource = v or 0 + Presets.pushLastVal = -1 + Presets.save() + end, + lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH, + "Assign a switch or button to manually push the current VTX config to the receiver." + ) + + -- Presets section + createSectionHeader(fields, "Presets") + + createHintRow(fields, "Assign a Band and Channel to each 6POS switch position.") + + local bandValues = { "Off", "A", "B", "E", "F", "R", "L" } + for i = 1, 6 do + local idx = i + local ctrl = createRow(fields, table.concat({ "Preset ", idx })) + + ctrl:choice({ + values = bandValues, + get = function() + return Presets.items[idx].band + 1 + end, + set = function(v) + Presets.items[idx].band = v - 1 + Presets.save() + end, + }) + + ctrl:numberEdit({ + min = 1, + max = 8, + get = function() + return Presets.items[idx].channel + end, + set = function(v) + Presets.items[idx].channel = v + Presets.save() + end, + visible = function() + return Presets.items[idx].band > 0 + end, + }) + end +end + +-- ============================================================================ +-- Widget lifecycle +-- ============================================================================ + +local lastBuilt6pos = -1 + +local wgt = { + zone = zone, + options = options, +} + +function wgt.background() + crsf:poll() + Protocol.tick() + Presets.process() + Presets.processPushSource() +end + +function wgt.refresh(_event, _touchState) + wgt.background() + if lvgl.isFullScreen() and Presets.lastPos ~= lastBuilt6pos then + lastBuilt6pos = Presets.lastPos + buildFullScreen() + end +end + +function wgt.update(newOptions) + wgt.options = newOptions + if lvgl.isFullScreen() then + if Protocol.isReady() then + VTX.syncDesiredFromState() + end + buildFullScreen() + lastBuilt6pos = Presets.lastPos + else + lastBuilt6pos = -1 + WidgetUI.build(wgt.zone, wgt.options) + end +end + +-- Initial build +WidgetUI.build(wgt.zone, wgt.options) + +return wgt diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua new file mode 100644 index 0000000..b5749fd --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -0,0 +1,46 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget -- +-- Displays VTX status (minimized) and allows full VTX configuration -- +-- (full-screen) via the CRSF config protocol to the ELRS TX module. -- +-- -- +-- Uses the loadable.lua pattern to minimize memory when not in use. -- +-- Requires /SCRIPTS/ELRSLib on the SD card for shared CRSF protocol. -- +--------------------------------------------------------------------------- + +local name = "ELRSVTXAdmin" + +-- selene: allow(undefined_variable) +local function create(zone, options) + if not _crsfSingleton then + local getCRSF = loadScript("/SCRIPTS/ELRS/crsf.lua") + ---@diagnostic disable-next-line: need-check-nil + _crsfSingleton = getCRSF() + end + local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + ---@diagnostic disable-next-line: need-check-nil + return loadable(zone, options, _crsfSingleton) +end + +local function refresh(widget, event, touchState) + widget.refresh(event, touchState) +end + +local function background(widget) + widget.background() +end + +local function update(widget, options) + widget.update(options) +end + +return { + name = "ExpressLRS VTX Admin", + create = create, + refresh = refresh, + background = background, + update = update, + options = { + { "Transparency", VALUE, 2, 0, 5 }, + }, + useLvgl = true, +} diff --git a/src/WIDGETS/ELRSVTXAdmin/presets.txt b/src/WIDGETS/ELRSVTXAdmin/presets.txt new file mode 100644 index 0000000..54f72a9 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/presets.txt @@ -0,0 +1,10 @@ +enabled=1 +source=92 +autoPushVtx=1 +pushSource=94 +p1=4,6 +p2=5,2 +p3=5,3 +p4=5,4 +p5=5,5 +p6=5,6 diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua new file mode 100644 index 0000000..442fea4 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -0,0 +1,289 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 800x480 (HD) -- +-- High definition landscape (TX16S Mark 3) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 800x480. +-- Reference zone heights (no deco → with deco): +-- 1/6: 69→~58 1/4: 104→~87 1/3: 139→116 1/2: 209→175 3/4: 313→~262 +-- Thresholds must work for both decorated and undecorated layouts. +WidgetUI.breakpoints = { + topBarW = 200, + sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + quarterH = 110, -- between 1/4 (~87-104) and 1/3 (116-139) + thirdH = 155, -- between 1/3 (116-139) and 1/2 (175-209) + halfH = 235, -- between 1/2 (175-209) and 3/4 (~262-313) +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = MIDSIZE }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = 0 }, +} + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, + VTX = VTX, +}) + +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 400 + local c1w = math.floor(w * 0.22) + local columns = { + { + type = lvgl.LABEL, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: band + power, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + band + power, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + MIDSIZE band + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + DBLSIZE band + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua new file mode 100644 index 0000000..9ab7d9f --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -0,0 +1,410 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen, widget zones differ significantly -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x480 portrait. +-- Portrait widget zones tend to be wider-relative-to-height than landscape. +-- Height tiers are scaled for the taller 480px screen. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 70, + quarterH = 100, + thirdH = 140, + halfH = 210, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +-- ============================================================================ +-- Minimized display helpers (portrait-specific overrides) +-- ============================================================================ + +--- Shorter detail line for narrow portrait screen. +local function detailLine() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "" + end + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({ pwr, pit }) +end + +--- Build two narrow cheatsheet rows (3 labels each), or nil pair. +local function buildCheatsheetNarrow() + local labels = VTXDisplay.build6posLabels() + if #labels == 0 then + return nil, nil + end + local row1, row2 = {}, {} + for i = 1, 3 do + row1[#row1 + 1] = labels[i] + end + for i = 4, 6 do + row2[#row2 + 1] = labels[i] + end + local hasModule = function() + return Protocol.state ~= Protocol.STATE_NO_MODULE + end + return { + type = lvgl.BOX, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = hasModule, + children = row1, + }, { + type = lvgl.BOX, + align = LEFT, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = hasModule, + children = row2, + } +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, + VTX = VTX, +}) + +--- 1/6: single row with band/channel + compact detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.28) + local columns = { + { + type = lvgl.LABEL, + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = detailLine, + }, + } + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: title + band/channel + power + cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.28) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeText, + }, + }, + }, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + status + cheatsheet. +--- Fixed-width status column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.28) + local rows = {} + -- Title row + rows[#rows + 1] = { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + -- Loading state: full-width status label + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + -- Active state: fixed-width band column + detail + rows[#rows + 1] = { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = detailLine, + }, + }, + } + -- Cheatsheet rows + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band/channel + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = function() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + local pwr = VTX.state.power > 0 and table.concat({ "Power ", VTX.state.power }) or "Power -" + local pit = VTX.state.pitmode and " Pit" or "" + return table.concat({ pwr, pit }) + end, + }, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE band/channel + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + if w < 200 then + local r1, r2 = buildCheatsheetNarrow() + if r1 then + rows[#rows + 1] = r1 + rows[#rows + 1] = r2 + end + else + local cs = VTXDisplay.buildCheatsheet() + if cs then + rows[#rows + 1] = cs + end + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua new file mode 100644 index 0000000..c4714e0 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -0,0 +1,286 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 480x272 (SD) -- +-- Standard definition landscape (TX15, T15 Pro, ST16, PL18, Horus) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + quarterH = 70, + thirdH = 100, + halfH = 125, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, + VTX = VTX, +}) + +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local columns = { + { + type = lvgl.LABEL, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: band + power. Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: title + band + detail, cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band/status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + band/status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua new file mode 100644 index 0000000..f3e5084 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -0,0 +1,313 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 480x320 (SD Tall) -- +-- TX16S, TX16S MAX, TX16S Mark II -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 480x320. +-- 48px taller than 480x272 so widget zones are proportionally taller. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + quarterH = 62, + thirdH = 118, + halfH = 147, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = MIDSIZE, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, + VTX = VTX, +}) + +--- 1/6: single row. Wide: band + detail + cheatsheet. Narrow: band + detail. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildSixth(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local columns = { + { + type = lvgl.LABEL, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, + } + if wide then + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: band + power (+ pit mode when wide). Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildQuarter(w, h, opa) + local wide = w > 200 + local c1w = math.floor(w * 0.22) + local row1 = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + } + if wide then + row1[#row1 + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeText, + } + end + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + visible = VTXDisplay.showChannel, + children = row1, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: three rows — title, band + detail, cheatsheet. +--- 480x320 has enough room for a title row. +--- Fixed-width band column prevents layout jumping when values change. +--- Status text (loading/error/off) uses unconstrained label for narrow columns. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = {} + -- Title row — 480x320 has more vertical room than 480x272 + rows[#rows + 1] = { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + rows[#rows + 1] = { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLine, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band/status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + band/status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/small.lua b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua new file mode 100644 index 0000000..6e28899 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -0,0 +1,347 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 320x240 (Small) -- +-- Small color LCD (PA01) -- +--------------------------------------------------------------------------- + +local ctx = ... +local VTX = ctx.VTX +local Protocol = ctx.Protocol +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x240. +-- Smallest color screen — everything is compact. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 38, + quarterH = 54, + thirdH = 76, + halfH = 100, +} + +WidgetUI.fonts = { + sixth = { status = BOLD }, + quarter = { status = BOLD }, + third = { status = BOLD }, + half = { hero = BOLD, detail = SMLSIZE }, + full = { hero = MIDSIZE, detail = SMLSIZE }, +} + +local function pitModeColor() + if not Protocol.isActive() or VTX.state.band == 0 then + return COLOR_THEME_SECONDARY1 + end + return VTX.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +local function pitModeText() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +local function pitModeTextLong() + if not Protocol.isActive() then + return "" + end + if VTX.state.band == 0 then + return "VTX Disabled" + end + return VTX.state.pitmode and "Pit Mode On" or "Pit Mode Off" +end + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + Protocol = Protocol, + VTX = VTX, +}) + +--- 1/6: single row with band + status + power + pit mode + cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildSixth(w, h, opa) + local c1w = math.floor(w * 0.22) + local columns = { + { + type = lvgl.LABEL, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + w = c1w, + font = WidgetUI.fonts.sixth.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeText, + }, + } + local labels = VTXDisplay.build6posLabels() + for _, lbl in ipairs(labels) do + columns[#columns + 1] = lbl + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: band + status + power + pit. Row 2: cheatsheet. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildQuarter(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = { + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + flexPad = lvgl.PAD_TINY, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.quarter.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = RED, + text = function() + if not Protocol.isActive() or VTX.state.band == 0 then + return "" + end + return VTX.state.pitmode and "Pit" or "" + end, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: band + status + power + pit mode, cheatsheet. No title on small screen. +--- Fixed-width band column prevents layout jumping when values change. +--- Loading state uses unconstrained label to avoid overflow in narrow columns. +function WidgetUI.buildThird(w, h, opa) + local c1w = math.floor(w * 0.22) + local rows = {} + -- Loading state: full-width status label + rows[#rows + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } + -- Active state: band + power + pit mode row (no title — too tight on 320x240) + rows[#rows + 1] = { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + w = c1w, + align = LEFT, + font = WidgetUI.fonts.third.status, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeText, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/2: title + band + status + detail + cheatsheet. +function WidgetUI.buildHalf(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.half.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + borderPad = 0, + children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = pitModeColor, + text = pitModeTextLong, + }, + }, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/1: title + MIDSIZE band + status + detail + cheatsheet. +function WidgetUI.buildFull(w, h, opa) + local rows = { + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.mainColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.hero, + color = VTXDisplay.mainColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = WidgetUI.fonts.full.detail, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.detailLong, + }, + } + local cheatsheet = VTXDisplay.buildCheatsheet() + if cheatsheet then + rows[#rows + 1] = cheatsheet + end + + WidgetLayout.column(w, h, opa, rows) +end + +--- Route to the appropriate minimized layout based on widget dimensions. +function WidgetUI.build(wgtZone, opts) + lvgl.clear() + local w, h = wgtZone.w, wgtZone.h + local opa = bgOpacity(opts) + local bp = WidgetUI.breakpoints + if w < bp.topBarW then + TopBarUI.build(w, h) + elseif h < bp.sixthH then + WidgetUI.buildSixth(w, h, opa) + elseif h < bp.quarterH then + WidgetUI.buildQuarter(w, h, opa) + elseif h < bp.thirdH then + WidgetUI.buildThird(w, h, opa) + elseif h < bp.halfH then + WidgetUI.buildHalf(w, h, opa) + else + WidgetUI.buildFull(w, h, opa) + end +end + +return WidgetUI diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua new file mode 100644 index 0000000..99366ea --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua @@ -0,0 +1,54 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - Shared Top Bar UI -- +-- Used by all screen-specific UI files for the top bar layout. -- +--------------------------------------------------------------------------- + +local ctx = ... +local Protocol = ctx.Protocol +local VTX = ctx.VTX + +local TopBarUI = {} + +local function getStatusLine() + if not Protocol.isActive() then + return "--" + end + if VTX.state.band == 0 then + return "--" + end + return table.concat({ VTX.state.bandLetter, VTX.state.channel }) +end + +--- Top bar: ultra-compact single line, no background. +function TopBarUI.build(w, h) + lvgl.build({ + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + align = CENTER + VCENTER, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + children = { + { + type = lvgl.LABEL, + align = CENTER, + font = MIDSIZE, + color = COLOR_THEME_PRIMARY2, + text = function() + local s = getStatusLine() + if s == "--" then + return s + end + local pwr = VTX.state.power > 0 and table.concat({ "P", VTX.state.power }) or "P-" + return table.concat({ s, pwr }, " ") + end, + }, + }, + }, + }) +end + +return TopBarUI