diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..6a2e7d6
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,22 @@
+root = true
+
+[*]
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+charset = utf-8
+
+[*.lua]
+indent_style = space
+indent_size = 2
+max_line_length = 120
+
+[Makefile]
+indent_style = tab
+
+[*.{yml,yaml}]
+indent_style = space
+indent_size = 2
+
+[*.md]
+trim_trailing_whitespace = false
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..5833982
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+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
+ with:
+ submodules: true
+
+ - 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..26e3198
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+*.luac
+src/WIDGETS/ELRSVTXAdmin/presets.txt
+bin/
+.claude/plans/
+TODO.md
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..9d1e9d4
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "edgetx-lua-stdlib"]
+ path = edgetx-lua-stdlib
+ url = https://github.com/jurgelenas/edgetx-lua-stdlib.git
+ branch = edgetx-2.12
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/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
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..cb62fcf 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,94 @@
+# 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.5.4+**
-### 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.
-
+## 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
+ crsf_params.lua -- parameter codec (tool, VTX Admin)
+ crsf_session.lua -- stateful parameter client (tool, VTX Admin)
+ crsf_elrsinfo.lua -- TX module info state (telemetry widget)
+ shim.lua -- BW compatibility shim
+ TOOLS/
+ ExpressLRS/
+ main.lua -- entry point
+ navigation.lua -- folder navigation
+ 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/` is required by the configuration tool and 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
+```
+
+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.
+
+
+
+
+
+## Widgets
+
+Both widgets running side-by-side on the home screen:
+
+
+
+## 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).
+
+
+
+## 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.
+
+
+
+## Development
+
+See [docs/development.md](docs/development.md) for the tool's internal architecture and the CRSF simulator used for testing inside the EdgeTX simulator.
+
+## Compatibility
+
+| Radio type | Firmware | ExpressLRS |
+|------------|----------|------------|
+| Black & white LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ |
+| Color LCD | EdgeTX 2.11.6+, 2.12.1+, or 3.0+ | v3.5.4+ |
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..c592f15
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,92 @@
+# Development
+
+This document covers the internal architecture of the ExpressLRS configuration tool and the CRSF simulator used for testing inside the EdgeTX simulator without real hardware.
+
+## Make targets
+
+Run `make help` to list all targets. The Makefile groups them into three categories:
+
+### Setup
+
+| Target | Purpose |
+|--------|---------|
+| `install-tools` | Install both `stylua` and `lua-language-server` |
+| `install-stylua` | Install [stylua](https://github.com/JohnnyMorganz/StyLua) via `cargo` (requires Rust toolchain). Built with the `lua53` feature for EdgeTX compatibility. |
+| `install-luals` | Download [lua-language-server](https://github.com/LuaLS/lua-language-server) `3.17.1` to `bin/lua-language-server/` |
+
+### Quality checks
+
+| Target | Purpose |
+|--------|---------|
+| `format` | Format all Lua sources in `src/` with `stylua` (config: `.stylua.toml`) |
+| `format-check` | Verify formatting without modifying files. Used in CI. |
+| `typecheck` | Run `lua-language-server --check .` against the project (config: `.luarc.json`) |
+| `check` | Convenience target: runs `format-check` then `typecheck` |
+
+### Deployment
+
+| Target | Purpose |
+|--------|---------|
+| `sync` | Copy sources to the EdgeTX simulator SD card at `../edgetx-sdcard` via `edgetx-cli dev sync`. Includes dev-only libraries like the CRSF simulator. |
+| `push` | Install the package to a connected EdgeTX radio via `edgetx-cli pkg install . --eject`. Excludes dev-only libraries. |
+
+## Architecture
+
+| Module | Purpose |
+|--------|---------|
+| `main.lua` | Entry point, run-loop orchestrator, and the App policy layer (device switching, folder-ready edges, the synthetic "Other Devices" row types) over a `crsf_session.lua` instance |
+| `navigation.lua` | Folder and device navigation stack |
+| `ui/lvgl.lua` | Color LCD interface (LVGL dialogs, command pages, warnings) |
+| `ui/lcd.lua` | BW LCD interface (text cursor, popups) |
+
+The tool builds on the shared `SCRIPTS/ELRS/` library, which the widgets use too:
+
+| Module | Purpose |
+|--------|---------|
+| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`push`), module detection, handler registry, stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) |
+| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget |
+| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget |
+| `SCRIPTS/ELRS/crsf_elrsinfo.lua` | Opt-in TX-module state: DEVICE_INFO cache, version-keyed RFMOD/RFRSSI tables, per-connection model-match latch. Loaded only by the telemetry widget |
+| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded only by the VTX Admin widget |
+| `SCRIPTS/ELRS/shim.lua` | Polyfills for BW radios missing standard Lua functions |
+
+Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table: `crsf:poll()` hands the
+same table to every registered handler, so an in-place decode would corrupt the frame for sibling
+handlers. `reassemble()` serves both receive models: the tool drains `crsf.pop()` itself and passes
+the field id it is waiting for, while widgets register a handler and pass `data[3]` to accept any
+field from their device.
+
+## CRSF Simulator
+
+The `src/SCRIPTS/CRSFSimulator/` library provides a CRSF protocol simulator for development and testing without real hardware. It is declared as a dev-only library in `edgetx.yml` (`dev: true`), so it is included by `edgetx-cli dev sync` but skipped by `edgetx-cli pkg install`.
+
+**File:** `src/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 running in the EdgeTX simulator (version string ends with `-simu`), `SCRIPTS/ELRS/crsf.lua` automatically loads the simulator module from `/SCRIPTS/CRSFSimulator/csrfsimulator.lua` at load time and patches its `pop`, `push`, `hasCrsfModule`, and `getSensorValue` functions with the mock implementations. The tool and the widgets both talk to CRSF through that library, so the mock covers all of them.
+
+Run `make sync` (which runs `edgetx-cli dev sync`) to copy the sources -- including the dev-only `CRSFSimulator` library -- onto the simulator SD card. `edgetx-cli pkg install` omits the library automatically, so the simulator is never shipped to 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. `ANT` alternates between 1 and 0 every ~5 seconds so both antenna branches render. |
+| `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. `ANT` is pinned to 0. |
+| `single_antenna` | TX + RX connected on a receiver with one RF path. `2RSS` is pinned to 0, so the telemetry widget reports no diversity. |
+| `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. |
+| `critical_error` | TX + RX connected with a critical baud-rate error flag. Triggers the warning screen; the suppress write clears it. |
+
+`config.maxPacketBytes` (default 64, `CRSF_MAX_PACKET_LEN`) is the largest frame the mock handset
+link carries. Parameter entries longer than `maxPacketBytes - 8` are chunked exactly as
+`CRSFEndpoint::sendParameter` does, so lowering it -- real firmware shrinks it on slow baud rates in
+`CRSFHandset::adjustMaxPacketSize` -- exercises chunk reassembly and the follow-up reads.
diff --git a/edgetx-lua-stdlib b/edgetx-lua-stdlib
new file mode 160000
index 0000000..9ecb146
--- /dev/null
+++ b/edgetx-lua-stdlib
@@ -0,0 +1 @@
+Subproject commit 9ecb146c1f9820e0a057d8bc55f56206d14cee5f
diff --git a/edgetx.yml b/edgetx.yml
new file mode 100644
index 0000000..0fcd6ff
--- /dev/null
+++ b/edgetx.yml
@@ -0,0 +1,52 @@
+package:
+ id: github.com/ExpressLRS/Lua-Scripts
+ name: ExpressLRS
+ description: ExpressLRS Lua scripts and widgets
+ authors:
+ - name: ExpressLRS Contributors
+ email: info@expresslrs.org
+ urls:
+ - name: Homepage
+ url: "https://www.expresslrs.org"
+ - name: Repository
+ url: "https://github.com/ExpressLRS/Lua-Scripts"
+ screenshots:
+ - screenshots/tool_main.png
+ - screenshots/tool_main_bw.png
+ - screenshots/widget_telemetry_fullscren.png
+ - screenshots/widget_vtxadmin_fullscreen.png
+ - screenshots/widgets.png
+ keywords:
+ - expresslrs
+ - elrs
+ - telemetry
+ license: GPL-3.0-only
+ source_dir: src
+ min_edgetx_version: "2.11.6"
+
+libraries:
+ - name: ELRS
+ path: SCRIPTS/ELRS
+
+ - name: CRSFSimulator
+ path: SCRIPTS/CRSFSimulator
+ dev: true
+
+tools:
+ - name: ExpressLRS
+ path: SCRIPTS/TOOLS/ExpressLRS
+ depends:
+ - ELRS
+
+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..ed04d49
--- /dev/null
+++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua
@@ -0,0 +1,1684 @@
+-- ============================================================================
+-- 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.
+-- ============================================================================
+
+-- B&W radios ship without the table library, so every table.* call here goes
+-- through the shared ELRS compat layer. Loading this eagerly is safe: the ELRS
+-- library is always installed, while this mock is dev-only.
+local shim = loadScript("/SCRIPTS/ELRS/shim.lua")()
+
+-- ============================================================================
+-- 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.
+-- "mismatch_cycle" Model-mismatch link that drops and returns (~10 s up,
+-- ~5 s down, forever). Exercises the per-connection status
+-- poll latch: one ELRS_STATUS request per connected phase,
+-- and the mismatch warning must clear while the link is down.
+-- "weak_link" TX + RX connected on a marginal link (RQly ~60, RSSI
+-- ~-85 dBm). Both the model-match poll and the VTX Admin
+-- folder poll must stay quiet here.
+-- "armed" TX + RX connected with the "is Armed" warning flag set.
+-- Shows armed warning in subtitle.
+-- "single_antenna" RX with a single RF path: 2RSS pinned to 0, so the
+-- widgets report no diversity.
+-- "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.
+-- "critical_error" TX + RX connected but the module reports a critical
+-- error (baud rate too low). Exercises the warning screen
+-- and the suppress-critical-errors write (field id 0x2E),
+-- which clears the flags until the script restarts.
+local config = {
+ scenario = "normal",
+ -- Largest frame the handset link carries (CRSF_MAX_PACKET_LEN on a fast
+ -- link). PARAMETER_SETTINGS_ENTRY payloads larger than maxPacketBytes - 8
+ -- are chunked exactly as CRSFEndpoint::sendParameter does. Real firmware
+ -- shrinks this on slow baud rates (CRSFHandset::adjustMaxPacketSize, floor
+ -- 15) -- lower it here to emulate a slow link and force deeper chunking.
+ maxPacketBytes = 64,
+}
+
+-- ============================================================================
+-- CRSF Protocol Constants (local copies, independent of the shared library)
+-- ============================================================================
+
+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_HANDSET = 0xEA, -- EdgeTX's official handset address
+ ADDRESS_RX = 0xEC,
+ ADDRESS_TX = 0xEE,
+ ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard CRSF
+
+ -- 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,
+
+ -- Pseudo-field id: a PARAMETER_WRITE to this id calls supressCriticalErrors()
+ -- in TXModuleEndpoint.cpp (the firmware uses the bare 0x2E literal).
+ FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E,
+}
+
+-- ============================================================================
+-- 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
+}
+
+-- ============================================================================
+-- Per-channel PWM output config (mirrors rx_config_pwm_t in firmware)
+-- Maps output channel index (1-4) to its Input Ch, Output Mode, and Invert.
+-- When Output Ch changes, siblings are loaded from this table.
+-- When siblings are edited, their values are saved back here.
+-- ============================================================================
+
+local pwmChannelConfig = {
+ [1] = { inputChannel = 1, mode = 0, inverted = 0 },
+ [2] = { inputChannel = 2, mode = 1, inverted = 0 },
+ [3] = { inputChannel = 3, mode = 2, inverted = 1 },
+ [4] = { inputChannel = 4, mode = 0, inverted = 0 },
+}
+
+-- ============================================================================
+-- 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
+
+-- 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 = shim.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_HANDSET)
+-- @return data table suitable for queuePush(FRAMETYPE_DEVICE_INFO, data)
+local function encodeDeviceInfo(device, destAddr)
+ local data = {}
+ data[1] = destAddr or CRSF.ADDRESS_HANDSET
+ 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, chunking at the handset link's frame
+-- limit exactly as CRSFEndpoint::sendParameter does: payloads larger than
+-- config.maxPacketBytes - 8 are sliced, each frame repeating the
+-- [dest, src, fieldId, chunksRemain] header with the countdown in
+-- chunksRemain.
+-- @param device the device table (for id)
+-- @param param the parameter definition table
+-- @param chunk requested chunk index (0-based)
+-- @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_HANDSET
+ 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
+
+ -- Chunking per CRSFEndpoint::sendParameter: the payload from the parent
+ -- byte onward is sliced into (maxPacketBytes - 8)-byte chunks -- 6 bytes of
+ -- CRSF header/CRC plus the FieldId + ChunksRemain pair repeated per frame.
+ local chunkMax = config.maxPacketBytes - 8
+ local body = {}
+ for i = 5, #data do
+ body[#body + 1] = data[i]
+ end
+ if #body <= chunkMax then
+ return data
+ end
+ local totalChunks = math.ceil(#body / chunkMax)
+ local k = chunk or 0
+ if k >= totalChunks then
+ k = totalChunks - 1
+ end
+ local out = { data[1], data[2], data[3], totalChunks - 1 - k }
+ for i = k * chunkMax + 1, math.min((k + 1) * chunkMax, #body) do
+ out[#out + 1] = body[i]
+ end
+ return out
+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_HANDSET
+ 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
+-- ============================================================================
+
+--- STR_LUA_ALLAUX_UPDOWN from CRSFParameters.h: "AUX1;AUX1;...;AUX10",
+--- where \192 and \193 are the ExpressLRS up/down arrow glyphs.
+local ALLAUX_UPDOWN = (function()
+ local opts = {}
+ for i = 1, 10 do
+ opts[#opts + 1] = shim.tableConcat({ "AUX", i, "\192" })
+ opts[#opts + 1] = shim.tableConcat({ "AUX", i, "\193" })
+ end
+ return shim.tableConcat(opts, ";")
+end)()
+
+local txDevice = {
+ id = CRSF.ADDRESS_TX,
+ name = "TX16S MK3",
+ serialNo = CRSF.ELRS_SERIAL_ID,
+ hwVer = 0,
+ swVer = 0x00030500, -- 3.5.0
+ fieldCount = 25, -- 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/Enable",
+ options = "Disabled;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 = shim.tableConcat({ "Off;On;", ALLAUX_UPDOWN }),
+ 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 = "",
+ -- Emits a different status string on each CMD_QUERY poll so the UI can be
+ -- checked for live status updates while a command is executing.
+ progress = { "Binding...", "Waiting for RX...", "RX found", "Saving..." },
+ },
+
+ -- Mirrors the Bind Phrase value. Editing the string below rewrites this INFO field
+ -- on the device side, so it only updates in the UI if the STRING write reloads its
+ -- sibling fields (reloadRelatedFields, not reloadParentFolder). Placed directly above
+ -- Bind Phrase so both stay on screen together while editing.
+ { id = 24, parent = 0, type = CRSF.INFO, name = "Phrase Echo", value = "default" },
+
+ -- 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" },
+
+ -- Signed integer field (INT8): exercises sign extension on load and the
+ -- two's-complement re-encode on save. Not a real TX parameter.
+ {
+ id = 25,
+ parent = 0,
+ type = CRSF.INT8,
+ name = "RF Gain",
+ value = -3,
+ min = -10,
+ max = 10,
+ default = 0,
+ units = "dB",
+ },
+ },
+}
+
+-- ============================================================================
+-- RX Device Definition (address 0xEC)
+-- Matches RXParameters.cpp parameter structure
+-- ============================================================================
+
+local rxDevice = {
+ id = CRSF.ADDRESS_RX,
+ name = "Bob 2400RX",
+ serialNo = CRSF.ELRS_SERIAL_ID,
+ hwVer = 0,
+ swVer = 0x00030500, -- 3.5.0
+ fieldCount = 25, -- 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 = "",
+ },
+
+ -- Gyro folder: exercises a COMMAND that changes a sibling value, so the tool
+ -- must re-read the current page after the command completes (Lua-Scripts #8).
+ { id = 19, parent = 0, type = CRSF.FOLDER, name = "Gyro" },
+ {
+ id = 20,
+ parent = 19,
+ type = CRSF.TEXT_SELECTION,
+ name = "Orientation",
+ options = "Up;Down;Left;Right",
+ value = 0,
+ units = "",
+ },
+ {
+ id = 21,
+ parent = 19,
+ type = CRSF.COMMAND,
+ name = "Detect Orientation",
+ status = CRSF.CMD_IDLE,
+ timeout = 50,
+ info = "",
+ -- Progress steps make the executing popup observable before completion.
+ progress = { "Detecting...", "Reading IMU..." },
+ -- On completion, cycle the Orientation value so each run visibly changes it.
+ onComplete = function(device, findParam)
+ local o = findParam(device, 20)
+ if o then
+ o.value = ((o.value or 0) + 1) % 4
+ end
+ end,
+ },
+
+ -- Bind Storage & Bind Mode
+ {
+ id = 22,
+ parent = 0,
+ type = CRSF.TEXT_SELECTION,
+ name = "Bind Storage",
+ options = "Persistent;Volatile;Returnable;Administered",
+ value = 0,
+ units = "",
+ },
+ {
+ id = 23,
+ parent = 0,
+ type = CRSF.COMMAND,
+ name = "Enter Bind Mode",
+ status = CRSF.CMD_IDLE,
+ timeout = 50,
+ info = "",
+ },
+
+ -- Model Id
+ { id = 24, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" },
+
+ -- Info fields
+ { id = 25, 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.
+-- Only the TX module builds these summaries; receiver parameters reuse the
+-- same ids for unrelated settings, so the ids below are meaningless there.
+-- @param device the device table whose params to update
+local function updateFolderNames(device)
+ if device.id ~= CRSF.ADDRESS_TX then
+ return
+ end
+
+ -- 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 "Disabled" -> 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)
+
+-- Set by a PARAMETER_WRITE to pseudo-field 0x2E (TXModuleEndpoint.cpp
+-- supressCriticalErrors): critical flag bits stay cleared afterwards.
+local criticalErrorsSuppressed = false
+
+local function getElrsFlags()
+ if config.scenario == "reconnect" then
+ return isRxAvailable() and 0x01 or 0x00
+ elseif config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then
+ return 0x05 -- connected + model mismatch
+ elseif config.scenario == "armed" then
+ return 0x09 -- connected + armed
+ elseif config.scenario == "critical_error" then
+ if criticalErrorsSuppressed then
+ return 0x01 -- connected, critical bits suppressed
+ end
+ return 0x41 -- connected + baud rate error (critical)
+ elseif
+ config.scenario == "normal"
+ or config.scenario == "slow_loading"
+ or config.scenario == "single_antenna"
+ or config.scenario == "weak_link"
+ then
+ return 0x01 -- connected
+ else
+ return 0x00 -- no telemetry
+ end
+end
+
+local function getElrsFlagsInfo()
+ if config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then
+ return "Model Mismatch"
+ elseif config.scenario == "armed" then
+ return "[ ! Armed ! ]"
+ elseif config.scenario == "critical_error" and not criticalErrorsSuppressed then
+ return "Baud rate too low"
+ 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
+ if param.persistent then
+ state.info = "Executing..."
+ state.queriesRemaining = nil -- runs until cancelled (e.g., WiFi)
+ elseif param.progress then
+ -- Step through the status strings, one per CMD_QUERY poll, so the
+ -- updated info text from the device can be observed in the UI.
+ state.progressIndex = 1
+ state.info = param.progress[1]
+ state.queriesRemaining = #param.progress
+ else
+ state.info = "Executing..."
+ 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"
+ -- Command finished naturally: apply any side effects (e.g. a command
+ -- that updates a sibling value). Not run on CMD_CANCEL.
+ if param.onComplete then
+ param.onComplete(device, findParam)
+ end
+ elseif param.progress then
+ -- Advance to the next status string for this poll.
+ state.progressIndex = (state.progressIndex or 1) + 1
+ state.info = param.progress[state.progressIndex] or state.info
+ 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
+
+ -- One line per pushed frame, so a scenario run's wire traffic can be counted
+ -- from the log (steady-state silence is an empty grep).
+ print(shim.tableConcat({
+ "CRSFSIM push t=",
+ getTime(),
+ " cmd=",
+ command,
+ " dst=",
+ data and data[1] or "-",
+ " field=",
+ data and data[3] or "-",
+ }))
+
+ if command == CRSF.FRAMETYPE_DEVICE_PING then
+ local dest = data[1] or CRSF.ADDRESS_BROADCAST
+ local replyTo = data[2] or CRSF.ADDRESS_HANDSET
+
+ -- Frames addressed to the TX module are answered on the handset UART and
+ -- never forwarded over the air, so only a broadcast ping reaches the RX.
+ if dest == CRSF.ADDRESS_BROADCAST or dest == CRSF.ADDRESS_TX then
+ queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, replyTo))
+ end
+
+ -- 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 dest == CRSF.ADDRESS_BROADCAST and isRxAvailable() then
+ queuePushDeferred(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(rxDevice, replyTo))
+ 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_HANDSET
+
+ 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_HANDSET
+ queuePush(CRSF.FRAMETYPE_ELRS_STATUS, encodeElrsStatus(deviceId, destAddr, 0, 250, flags, flagsInfo))
+ return true
+ end
+
+ -- Special case: suppress-critical-errors write (TXModuleEndpoint.cpp).
+ if fieldId == CRSF.FIELD_ID_SUPPRESS_CRITICAL_ERRORS then
+ criticalErrorsSuppressed = true
+ 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_HANDSET
+ 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 = shim.charsToString(chars)
+ -- Bind Phrase: mirror into the Phrase Echo INFO sibling so the UI only
+ -- reflects the change if the STRING write reloads sibling fields.
+ if param.id == 20 then
+ local echo = findParam(device, 24)
+ if echo then
+ echo.value = param.value
+ end
+ end
+ 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
+ elseif t == CRSF.INT8 then
+ local v = writeValue or 0
+ if v >= 0x80 then
+ v = v - 0x100
+ end
+ param.value = v
+ else
+ param.value = writeValue
+ end
+ -- Dynamic power off hides Fan Thresh: mimic firmware visibility
+ -- rules driven by sibling values, so a write can flip a field's
+ -- hidden bit and the Lua script sees it on the sibling re-read.
+ if device.id == CRSF.ADDRESS_TX and param.id == 8 then
+ local fanThresh = findParam(device, 9)
+ if fanThresh then
+ fanThresh.hidden = (param.value == 0) or nil
+ end
+ end
+ -- Output Mapping per-channel config: mimic firmware behavior
+ -- where changing Output Ch loads sibling values from per-channel config,
+ -- and editing siblings saves back to the current channel's config.
+ if device.id == CRSF.ADDRESS_RX then
+ if param.id == 9 then
+ -- Output Ch changed: load config for the selected channel
+ local cfg = pwmChannelConfig[param.value]
+ if cfg then
+ local inputChParam = findParam(device, 10)
+ local outputModeParam = findParam(device, 11)
+ local invertParam = findParam(device, 12)
+ if inputChParam then
+ inputChParam.value = cfg.inputChannel
+ end
+ if outputModeParam then
+ outputModeParam.value = cfg.mode
+ end
+ if invertParam then
+ invertParam.value = cfg.inverted
+ end
+ end
+ elseif param.id == 10 or param.id == 11 or param.id == 12 then
+ -- Sibling edited: save back to current output channel's config
+ local outputChParam = findParam(device, 9)
+ local ch = outputChParam and outputChParam.value or 1
+ local cfg = pwmChannelConfig[ch]
+ if cfg then
+ if param.id == 10 then
+ cfg.inputChannel = param.value
+ end
+ if param.id == 11 then
+ cfg.mode = param.value
+ end
+ if param.id == 12 then
+ cfg.inverted = param.value
+ end
+ end
+ end
+ 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 = shim.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,
+ },
+ -- A receiver with one RF path: it never writes uplink_RSSI_2, so 2RSS arrives
+ -- as 0 dBm and the widget should report no diversity.
+ single_antenna = {
+ TPWR = 50,
+ RFMD = 7,
+ ["1RSS"] = -84,
+ ["2RSS"] = 0,
+ RQly = 97,
+ ANT = 0,
+ RxBt = 15.1,
+ Curr = 11.0,
+ FM = "ACRO",
+ Sats = 11,
+ GSpd = 22.4,
+ Alt = 120,
+ },
+ 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,
+ },
+ -- Bench-realistic signal: a mismatch is caught next to the quad, and the
+ -- active-antenna RSSI must clear the model-match poll's -70 dBm gate.
+ model_mismatch = {
+ TPWR = 50,
+ RFMD = 7,
+ ["1RSS"] = -55,
+ ["2RSS"] = -58,
+ RQly = 95,
+ ANT = 1,
+ RxBt = 15.8,
+ Curr = 0.5,
+ },
+ -- Same signal as model_mismatch; RQly is driven by sensorToggle so the link
+ -- drops and returns forever (~10 s up, ~5 s down).
+ mismatch_cycle = {
+ TPWR = 50,
+ RFMD = 7,
+ ["1RSS"] = -55,
+ ["2RSS"] = -58,
+ RQly = 95,
+ ANT = 1,
+ RxBt = 15.8,
+ Curr = 0.5,
+ },
+ -- Marginal link: RQly never 0 and never above 90, active-antenna RSSI never
+ -- above -70 dBm, so every gated poll must stay quiet.
+ weak_link = {
+ TPWR = 250,
+ RFMD = 7,
+ ["1RSS"] = -85,
+ ["2RSS"] = -88,
+ RQly = 60,
+ ANT = 0,
+ RxBt = 15.2,
+ Curr = 12.5,
+ FM = "ACRO",
+ Sats = 9,
+ GSpd = 31.0,
+ Alt = 210,
+ },
+ 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,
+ },
+ critical_error = {
+ -- Same link as normal; only the ELRS status flags differ.
+ 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,
+}
+
+-- Per-scenario sensors that step through a fixed sequence instead of jittering,
+-- so both branches of an enum sensor are reachable within one simulator run.
+-- Takes precedence over sensorJitter and over the scenario's base value; one
+-- entry is consumed per cache refresh, i.e. one per second. A single-entry
+-- sequence pins a value that would otherwise be jittered.
+local sensorToggle = {
+ normal = {
+ -- Alternate antennas so both the "Ant 1" and "Ant 2" branches render.
+ ANT = { 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 }, -- ~5 s per antenna
+ },
+ single_antenna = {
+ -- Must stay exactly 0: that is what marks the second RF path as absent.
+ ["2RSS"] = { 0 },
+ },
+ mismatch_cycle = {
+ -- ~10 s connected, ~5 s down, repeating. Exactly 95 or 0 so the
+ -- RQly-derived connection state flips cleanly on each phase change.
+ RQly = { 95, 95, 95, 95, 95, 95, 95, 95, 95, 95, 0, 0, 0, 0, 0 },
+ },
+}
+local toggleStep = 0
+
+-- 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 = {}
+ toggleStep = toggleStep + 1
+ local toggles = sensorToggle[config.scenario]
+ for sensorId, base in pairs(t) do
+ local seq = toggles and toggles[sensorId]
+ local jit = sensorJitter[sensorId]
+ if seq then
+ telemetryCache[sensorId] = seq[(toggleStep % #seq) + 1]
+ elseif 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..28bfce5
--- /dev/null
+++ b/src/SCRIPTS/ELRS/crsf.lua
@@ -0,0 +1,320 @@
+---------------------------------------------------------------------------
+-- 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/ELRS/crsf.lua. --
+-- Returns a table with protocol constants, handler registry, and --
+-- pop queue dispatcher. --
+---------------------------------------------------------------------------
+
+local shim = loadScript("/SCRIPTS/ELRS/shim.lua")()
+
+local CRSF = {}
+
+-- ============================================================================
+-- Named protocol constants
+-- ============================================================================
+
+CRSF.CONST = {
+ -- Addresses
+ ADDRESS_BROADCAST = 0x00,
+ ADDRESS_HANDSET = 0xEA, -- EdgeTX's official handset address
+ ADDRESS_RX = 0xEC,
+ ADDRESS_TX = 0xEE,
+ ADDRESS_HANDSET_ELRS = 0xEF, -- ELRS-custom Lua device address, not standard 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,
+
+ -- Field types (for parsing PARAMETER_SETTINGS_ENTRY responses)
+ FIELD_UINT8 = 0,
+ FIELD_INT8 = 1,
+ FIELD_UINT16 = 2,
+ FIELD_INT16 = 3,
+ FIELD_UINT32 = 4,
+ FIELD_INT32 = 5,
+ FIELD_UINT64 = 6,
+ FIELD_INT64 = 7,
+ FIELD_FLOAT = 8,
+ FIELD_TEXT_SELECTION = 9,
+ FIELD_STRING = 10,
+ FIELD_FOLDER = 11,
+ FIELD_INFO = 12,
+ FIELD_COMMAND = 13,
+ FIELD_VTX = 15,
+
+ -- Command steps (commandStep_e in ExpressLRS CRSFParameters.h)
+ CMD_IDLE = 0,
+ CMD_CLICK = 1, -- user has clicked the command to execute
+ CMD_EXECUTING = 2, -- command is executing
+ CMD_ASKCONFIRM = 3, -- command pending user OK
+ CMD_CONFIRMED = 4, -- user has confirmed
+ CMD_CANCEL = 5, -- user has requested cancel
+ CMD_QUERY = 6, -- UI is requesting status update
+
+ -- ELRS identification (serial number field in DEVICE_INFO)
+ 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
+
+ -- 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 = {}
+
+-- Link state: derived from RQly in poll()
+CRSF.hasTelemetry = false
+
+-- Tick guard for poll()
+CRSF._lastPollTick = 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 once it succeeds; a sensor that is
+-- not discovered yet is retried on every call, so it starts reading as soon as
+-- EdgeTX creates it (e.g. sensor discovery running after the widget loaded).
+-- 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)
+ if info == nil then
+ return nil
+ end
+ cid = info.id
+ CRSF._vCache[id] = cid
+ end
+ return getValue(cid)
+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()
+---@diagnostic disable-next-line: cast-local-type
+setMock = nil
+
+-- ============================================================================
+-- 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
+ self._handlers[frameType][#self._handlers[frameType] + 1] = callback
+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
+
+ -- Connection state is derived from link quality at zero wire cost: the ELRS
+ -- TX zeroes RQly on disconnect, so a present, positive value is the truth
+ -- about the link. Derived after the drain so a frame from a dying connection
+ -- is dispatched before consumers observe the flip; handlers that keep
+ -- per-connection state key their resets off that ordering (see
+ -- /SCRIPTS/ELRS/crsf_elrsinfo.lua).
+ self.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0
+end
+
+-- ============================================================================
+-- Shared helpers
+-- ============================================================================
+
+--- Read a null-terminated string from a CRSF data array without mutating it.
+-- poll() hands the same data table to every handler registered for a frame
+-- type, so decoding must never write into the frame.
+-- @param data array of byte values
+-- @param off 1-based start offset
+-- @return string, nextOffset
+local function readString(data, off)
+ local parts = {}
+ local b = data[off]
+ while b and b ~= 0 do
+ parts[#parts + 1] = string.char(b)
+ off = off + 1
+ b = data[off]
+ end
+ return shim.tableConcat(parts), off + 1
+end
+
+--- Decode a DEVICE_INFO (0x29) frame.
+-- Payload after [dest, src]: name (null-terminated), serial (4B BE),
+-- hwVer (4B), swVer (4B, low three bytes are maj.min.rev), fieldCount (1B),
+-- parameter protocol version (1B). No address gate: callers gate on the
+-- returned id.
+-- @param data array of byte values
+-- @return table with id (source address), name, isElrs (true/nil), fieldCount,
+-- vMaj, vMin, vRev -- or nil if the frame is shorter than the layout
+function CRSF:decodeDeviceInfo(data)
+ local id = data[2]
+ local name, off = readString(data, 3)
+ if data[off + 12] == nil then
+ return nil -- shorter than the fixed layout; the caller's ping retries
+ end
+ local serial = ((data[off] * 256 + data[off + 1]) * 256 + data[off + 2]) * 256 + data[off + 3]
+ return {
+ id = id,
+ name = name,
+ isElrs = (serial == CRSF.CONST.ELRS_SERIAL_ID) or nil,
+ fieldCount = data[off + 12],
+ vMaj = data[off + 9],
+ vMin = data[off + 10],
+ vRev = data[off + 11],
+ }
+end
+
+--- Decode an ELRS_STATUS (0x2E) frame (the answer to requestElrsStatus()).
+-- No address gate: callers gate on the returned id.
+-- @param data array of byte values
+-- @return table with id (source address), lostPackets, receivedPackets, flags
+-- (raw byte, for threshold checks), connected / modelMismatch /
+-- criticalError (true/nil), warning (always a string, "" when the
+-- module sends none) -- or nil if the frame is shorter than the
+-- flags byte
+function CRSF:decodeElrsStatus(data)
+ if data[6] == nil then
+ return nil
+ end
+ local flags = data[6]
+ local warning = readString(data, 7)
+ return {
+ id = data[2],
+ lostPackets = data[3],
+ receivedPackets = data[4] * 256 + data[5],
+ flags = flags,
+ connected = bit32.btest(flags, 1) or nil,
+ modelMismatch = bit32.btest(flags, 4) or nil,
+ criticalError = (flags > CRSF.CONST.ELRS_FLAGS_WARNING_THRESHOLD) or nil,
+ warning = warning,
+ }
+end
+
+--- ELRS 1.x signature: an inbound PARAMETER_WRITE addressed to the official
+-- handset address from the TX module. 3.x+ answers on ADDRESS_HANDSET_ELRS and
+-- never writes to the handset. Reads data[1] (the destination) deliberately --
+-- unlike the decoders above, which leave gating on the source to the caller.
+-- @param data array of byte values
+-- @return true when the frame matches the 1.x signature, nil otherwise
+function CRSF:isElrsV1Frame(data)
+ return (data[1] == CRSF.CONST.ADDRESS_HANDSET and data[2] == CRSF.CONST.ADDRESS_TX) or nil
+end
+
+--- Send a DEVICE_PING.
+-- A ping addressed to a specific device is answered on the handset UART and
+-- never forwarded over the air; a broadcast ping is also forwarded to the RX
+-- while the link is up, costing over-the-air round trips. Broadcast only when
+-- discovering remote devices.
+-- @param dest CRSF device address (use CRSF.CONST.ADDRESS_*); nil broadcasts
+function CRSF:pingDevices(dest)
+ CRSF.push(CRSF.CONST.FRAMETYPE_DEVICE_PING, { dest or CRSF.CONST.ADDRESS_BROADCAST, CRSF.CONST.ADDRESS_HANDSET })
+end
+
+--- Request ELRS status from the TX module (PARAMETER_WRITE with fieldId=0).
+-- The module answers with an ELRS_STATUS frame carrying its warning flags.
+function CRSF:requestElrsStatus()
+ CRSF.push(CRSF.CONST.FRAMETYPE_PARAMETER_WRITE, { CRSF.CONST.ADDRESS_TX, CRSF.CONST.ADDRESS_HANDSET_ELRS, 0, 0 })
+end
+
+-- ============================================================================
+-- Return singleton
+-- ============================================================================
+
+return CRSF
diff --git a/src/SCRIPTS/ELRS/crsf_elrsinfo.lua b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua
new file mode 100644
index 0000000..d0963cc
--- /dev/null
+++ b/src/SCRIPTS/ELRS/crsf_elrsinfo.lua
@@ -0,0 +1,299 @@
+---------------------------------------------------------------------------
+-- ELRS TX Module Info --
+-- --
+-- Opt-in stateful companion to the CRSF singleton: DEVICE_INFO cache --
+-- (module name, version-keyed RFMOD/RFRSSI lookup tables) and the --
+-- per-connection model-match status, fed by frame handlers registered --
+-- on the shared CRSF singleton. Loaded once per Lua state and shared by --
+-- every widget instance; widgets that do not need this data never load --
+-- it, so they never pay for the tables. --
+-- --
+-- update() is the per-tick pump: call it right after crsf:poll(). --
+---------------------------------------------------------------------------
+
+local crsf = ...
+
+local ElrsInfo = {}
+
+-- ============================================================================
+-- State
+-- ============================================================================
+
+-- Device info cache (populated by the DEVICE_INFO handler):
+-- name, isElrs, RFMOD, RFRSSI
+ElrsInfo.deviceInfo = {}
+
+-- Model match comes from the ELRS_STATUS answer updateModelMatch() requests
+-- once per connection: a connect-time snapshot, fine for model match, which
+-- is decided at connect.
+---@type boolean?
+ElrsInfo.modelMismatch = nil
+
+-- Device info polling
+ElrsInfo._lastDevPoll = 0
+
+-- ELRS status polling
+ElrsInfo._lastStatusPoll = 0
+
+-- Set once the current connection's ELRS_STATUS answer has arrived, so each
+-- connection is polled for model match at most once. Cleared with the rest of
+-- the per-connection state on any hasTelemetry edge in update().
+---@type boolean?
+ElrsInfo._statusAnswered = nil
+
+-- hasTelemetry as last seen by update(), to detect connection edges.
+ElrsInfo._wasConnected = false
+
+-- Effective major version of the RFMOD/RFRSSI tables currently built. The
+-- only version datum kept, purely so selectRfTables() can skip rebuilds.
+---@type number?
+ElrsInfo._rfMaj = nil
+
+-- ============================================================================
+-- Helpers
+-- ============================================================================
+
+--- RSSI of the antenna currently in use, or nil while unknown.
+local function getActiveRssi()
+ local ant = crsf.getSensorValue("ANT")
+ return (ant == 1) and crsf.getSensorValue("2RSS") or crsf.getSensorValue("1RSS")
+end
+
+-- ============================================================================
+-- Outgoing requests
+-- ============================================================================
+
+--- Ask the TX module for its DEVICE_INFO if it is not cached yet.
+-- Addressed to the module itself, so it stays off the air. EdgeTX pings once
+-- at module init, but that answer lands before any widget's Lua queue exists
+-- (queues are created lazily on the first crossfireTelemetryPop), so widgets
+-- must ask themselves. Rate-limited to at most once per second, permanently
+-- quiet once answered.
+local function requestDeviceInfo()
+ if ElrsInfo.deviceInfo.name then
+ return
+ end
+ local now = getTime()
+ if now - ElrsInfo._lastDevPoll < 100 then
+ return
+ end
+ ElrsInfo._lastDevPoll = now
+ crsf:pingDevices(crsf.CONST.ADDRESS_TX)
+end
+
+-- Weakest link updateModelMatch() will spend a frame on: a mismatch is caught
+-- next to the quad, and on a marginal link every RC-channels frame matters.
+local MODEL_MATCH_MIN_RSSI = -70
+
+--- Keep modelMismatch current at about one status request per connection.
+-- requestElrsStatus() is answered locally, but still replaces one RC-channels
+-- frame on the handset->module UART, so it is sent only when it can matter
+-- and can be afforded: while connected, from a module that identifies as
+-- ExpressLRS (other CRSF modules never answer the fieldId=0 convention), on
+-- a strong link, and only until the current connection's answer arrives.
+-- Retries at most once per second while unanswered.
+local function updateModelMatch()
+ if not (crsf.hasTelemetry and ElrsInfo.deviceInfo.isElrs) or ElrsInfo._statusAnswered then
+ return
+ end
+ local rssi = getActiveRssi()
+ if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then
+ return
+ end
+ local now = getTime()
+ if now - ElrsInfo._lastStatusPoll < 100 then
+ return
+ end
+ ElrsInfo._lastStatusPoll = now
+ crsf:requestElrsStatus()
+end
+
+--- Per-tick pump; call right after crsf:poll() so the tick's frames are
+-- dispatched and hasTelemetry is current. Any hasTelemetry edge wipes the
+-- per-connection state before updateModelMatch() runs: a stale modelMismatch
+-- can neither survive a disconnect nor suppress the next connection's status
+-- poll via a late-arriving answer. poll() dispatches frames before it derives
+-- hasTelemetry, so a dying connection's ELRS_STATUS always lands before the
+-- edge is observed here.
+function ElrsInfo:update()
+ local connected = crsf.hasTelemetry
+ if connected ~= self._wasConnected then
+ self._wasConnected = connected
+ self.modelMismatch = nil
+ self._statusAnswered = nil
+ end
+ requestDeviceInfo()
+ updateModelMatch()
+end
+
+-- ============================================================================
+-- Frame handlers
+-- ============================================================================
+
+--- Install the RFMOD/RFRSSI lookup tables for an ELRS major version.
+-- The highest known version at or below vMaj wins, so newer firmware
+-- degrades to the newest known tables instead of losing its rate names.
+-- Rebuilt only when the effective version changes (first answer, module
+-- swap across reconnects), never per frame.
+local function selectRfTables(vMaj)
+ local effMaj
+ if vMaj >= 4 then
+ effMaj = 4
+ elseif vMaj == 3 then
+ effMaj = 3
+ end
+ if ElrsInfo._rfMaj == effMaj then
+ return
+ end
+ ElrsInfo._rfMaj = effMaj
+
+ local info = ElrsInfo.deviceInfo
+ if effMaj == 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 effMaj == 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,
+ }
+ else
+ info.RFMOD = nil
+ info.RFRSSI = nil
+ end
+end
+
+-- DEVICE_INFO handler: caches module name/identity and selects the RF tables
+local function onDeviceInfo(data)
+ local info = crsf:decodeDeviceInfo(data)
+ if info == nil or info.id ~= crsf.CONST.ADDRESS_TX then
+ return -- short frame (the ping retries) or not the TX module
+ end
+ ElrsInfo.deviceInfo.name = info.name
+ ElrsInfo.deviceInfo.isElrs = info.isElrs
+ selectRfTables(info.vMaj)
+end
+
+-- ELRS_STATUS handler: latches the answer and updates modelMismatch
+local function onElrsStatus(data)
+ local status = crsf:decodeElrsStatus(data)
+ if status == nil or status.id ~= crsf.CONST.ADDRESS_TX then
+ return
+ end
+ ElrsInfo._statusAnswered = true
+ ElrsInfo.modelMismatch = status.modelMismatch
+end
+
+crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, onDeviceInfo)
+crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, onElrsStatus)
+
+-- ============================================================================
+-- Return singleton
+-- ============================================================================
+
+return ElrsInfo
diff --git a/src/SCRIPTS/ELRS/crsf_params.lua b/src/SCRIPTS/ELRS/crsf_params.lua
new file mode 100644
index 0000000..4942b41
--- /dev/null
+++ b/src/SCRIPTS/ELRS/crsf_params.lua
@@ -0,0 +1,442 @@
+---------------------------------------------------------------------------
+-- CRSF Parameter Codec --
+-- --
+-- Pure codecs for CRSF parameter traffic: byte getters, the per-type --
+-- PARAMETER_SETTINGS_ENTRY (0x2B) payload decoders, chunk reassembly --
+-- over a caller-owned rx-state table, and frame encoders that return --
+-- (frameType, payload) for the caller to push. Opt-in: only consumers --
+-- that read or write parameter fields load it, so telemetry-only --
+-- widgets never pay for it. Policy -- load queues, command popups, --
+-- reload decisions, transport -- stays with the caller. --
+-- --
+-- Purity rule: nothing in this file mutates a frame data table. --
+-- crsf:poll() hands the same table to every registered handler, so an --
+-- in-place decode here would corrupt the frame for sibling handlers. --
+-- --
+-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf). --
+-- Returns the codec table directly. --
+---------------------------------------------------------------------------
+
+local crsf = ...
+
+local shim = loadScript("/SCRIPTS/ELRS/shim.lua")()
+
+local Params = {}
+
+-- ============================================================================
+-- Byte getters
+-- ============================================================================
+
+--- Read a big-endian unsigned integer from a byte array.
+-- @param data array of byte values
+-- @param offset 1-based start offset
+-- @param size width in bytes
+-- @return number
+function Params.readValue(data, offset, size)
+ local result = 0
+ for i = 0, size - 1 do
+ result = bit32.lshift(result, 8) + data[offset + i]
+ end
+ return result
+end
+
+--- Read a null-terminated string, or a semicolon-separated option list, from
+-- a byte array without mutating it. Translates the legacy ELRS arrow bytes
+-- (0xC0/0xC1) to the EdgeTX CHAR_UP/CHAR_DOWN glyphs.
+-- @param data array of byte values
+-- @param offset 1-based start offset
+-- @param last cached previous result: when given, decoding is skipped and
+-- it is returned as-is (the offset still advances past the
+-- terminator). Only safe while no reload has flagged the
+-- content as possibly changed.
+-- @param isOpts truthy to split on ';' and return a table of options
+-- @return string|table result, number nextOffset, number optCount (count of
+-- non-empty options; 0 when cached or not isOpts)
+function Params.readStringOrOpts(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
+
+-- ============================================================================
+-- Per-type field loaders
+-- ============================================================================
+
+local function fieldUnsignedLoad(field, data, offset, size, unitoffset)
+ field.value = Params.readValue(data, offset, size)
+ field.min = Params.readValue(data, offset + size, size)
+ field.max = Params.readValue(data, offset + 2 * size, size)
+ local unit = Params.readStringOrOpts(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
+
+local function 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
+
+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 = Params.readValue(data, offset + 17, 4)
+ field.fmt = shim.tableConcat({ "%.", tostring(field.prec), "f" })
+ field.prec = 10 ^ field.prec
+end
+
+local function fieldTextSelLoad(field, data, offset)
+ local vcnt
+ local oldValues = field.values
+ local cached = field.dirty == nil and oldValues
+ field.values, offset, vcnt = Params.readStringOrOpts(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 = Params.readStringOrOpts(data, offset + 4)
+ field.unit = (unit ~= "") and unit or nil
+ field.dirty = nil
+end
+
+local function fieldStringLoad(field, data, offset)
+ field.value, offset = Params.readStringOrOpts(data, offset)
+ if #data >= offset then
+ field.maxlen = data[offset]
+ end
+end
+
+local function fieldCommandLoad(field, data, offset)
+ field.status = data[offset]
+ field.timeout = data[offset + 1]
+ local info = Params.readStringOrOpts(data, offset + 2)
+ field.info = (info ~= "") and info or nil
+end
+
+local function fieldFolderLoad(field, data, offset)
+ field.children = {}
+ while data[offset] and data[offset] ~= crsf.CONST.FIELD_LIST_END do
+ field.children[#field.children + 1] = data[offset]
+ offset = offset + 1
+ end
+end
+
+-- Per-type load dispatch, keyed by wire type id + 1.
+-- UINT32..INT64 are unsupported (nil slots), as in the ELRS firmware.
+local handlers = {
+ [crsf.CONST.FIELD_UINT8 + 1] = fieldIntLoad,
+ [crsf.CONST.FIELD_INT8 + 1] = fieldIntLoad,
+ [crsf.CONST.FIELD_UINT16 + 1] = fieldIntLoad,
+ [crsf.CONST.FIELD_INT16 + 1] = fieldIntLoad,
+ [crsf.CONST.FIELD_FLOAT + 1] = fieldFloatLoad,
+ [crsf.CONST.FIELD_TEXT_SELECTION + 1] = fieldTextSelLoad,
+ [crsf.CONST.FIELD_STRING + 1] = fieldStringLoad,
+ [crsf.CONST.FIELD_FOLDER + 1] = fieldFolderLoad,
+ [crsf.CONST.FIELD_INFO + 1] = fieldStringLoad,
+ [crsf.CONST.FIELD_COMMAND + 1] = fieldCommandLoad,
+}
+
+-- ============================================================================
+-- Chunk reassembly
+--
+-- PARAMETER_SETTINGS_ENTRY payloads larger than the handset link's frame
+-- limit arrive in chunks (CRSFEndpoint::sendParameter). Reassembly state
+-- lives in a caller-owned rx table with these keys:
+-- chunk next chunk index to request (readable; do not write)
+-- data reassembly buffer for the in-flight entry
+-- dataId the field id the buffer belongs to
+-- expect duplicate-frame guard (chunks expected to remain)
+-- done field whose multi-chunk entry just completed, to swallow the
+-- other consumers' trailing final chunks
+-- Consumers initialize rx = { chunk = 0, expect = -1 }.
+-- ============================================================================
+
+--- Abandon any in-flight reassembly.
+-- @param rx the reassembly-state table
+function Params.resetChunks(rx)
+ rx.chunk = 0
+ rx.data = nil
+ rx.dataId = nil
+ rx.done = nil
+end
+
+--- Feed one PARAMETER_SETTINGS_ENTRY frame into the reassembly.
+-- expectedFieldId selects the consumption model: a caller waiting on one
+-- specific field passes its id (nil while idle drops everything), a caller
+-- listening passively under the poll() fan-out passes data[3] to accept any
+-- field from deviceId -- the dataId gate then keeps a sibling-elicited entry
+-- for another field out of an in-flight buffer.
+-- Never mutates data, and never retains it: the single-frame fast path
+-- returns data itself as the buffer, valid only for the current call.
+-- @param rx the reassembly-state table
+-- @param deviceId the device address answers must come from
+-- @param data the frame's byte array
+-- @param expectedFieldId the field id to accept
+-- @return fieldId, buffer, offset entry complete; buffer[offset] is the
+-- parent byte, ready for decodeEntry
+-- @return fieldId chunk consumed, more expected -- send the
+-- next read, which carries the updated rx.chunk
+-- @return nil frame dropped (wrong device or field,
+-- cross-field continuation, duplicate chunk)
+function Params.reassemble(rx, deviceId, data, expectedFieldId)
+ -- Another device answered, or this is not the awaited field: drop any
+ -- partial data
+ if data[2] ~= deviceId or data[3] ~= expectedFieldId then
+ Params.resetChunks(rx)
+ return nil
+ end
+ -- An in-flight buffer only accepts continuation frames for its own field
+ if rx.data and rx.dataId ~= data[3] then
+ return nil
+ end
+ local chunksRemain = data[4]
+ -- Trailing duplicates of a multi-chunk entry: when several consumers each
+ -- request the same field, every rx sees every answer, and the extra copies
+ -- of the final chunk arrive back to back after this rx already completed
+ -- the entry. Their header is indistinguishable from a fresh single-frame
+ -- entry, so they would decode as garbage. Swallow them until a new request
+ -- cycle starts -- traffic for another field, or our own encodeRead, both
+ -- of which clear done.
+ if rx.done then
+ if rx.done == data[3] then
+ if chunksRemain == 0 and not rx.data then
+ return nil
+ end
+ else
+ rx.done = nil
+ end
+ end
+ -- chunksRemain changed while data is buffered: duplicate frame, drop it
+ if rx.data and chunksRemain ~= rx.expect then
+ return nil
+ end
+
+ local buffer
+ local offset
+ -- If data is chunked, copy it to the persistent buffer
+ if chunksRemain > 0 or rx.chunk > 0 then
+ rx.data = rx.data or {}
+ rx.dataId = data[3]
+ buffer = rx.data
+ for i = 5, #data do
+ buffer[#buffer + 1] = data[i]
+ end
+ offset = 1
+ else
+ -- All data arrived in one chunk, hand the frame back directly
+ buffer = data
+ offset = 5
+ end
+
+ if chunksRemain > 0 then
+ rx.chunk = rx.chunk + 1
+ rx.expect = chunksRemain - 1
+ return data[3]
+ end
+
+ local wasChunked = rx.chunk > 0
+ Params.resetChunks(rx)
+ if wasChunked then
+ rx.done = data[3]
+ end
+ return data[3], buffer, offset
+end
+
+-- ============================================================================
+-- Entry decode
+-- ============================================================================
+
+--- Decode a complete PARAMETER_SETTINGS_ENTRY payload into the caller-owned
+-- field table. Sets field.id, parent (0 -> nil), type (0x7F-masked), hidden
+-- (0x80 bit, true/nil), name, and dispatches the per-type loader, which fills
+-- value/min/max/unit (ints, floats), prec/step/fmt (floats), values/disabled
+-- (text selections), maxlen (strings), status/timeout/info (commands) or
+-- children (folders). min/max of 0 are normalized to nil.
+-- @param field the caller-owned field table to decode into
+-- @param fieldId the field id the payload belongs to
+-- @param buffer byte array holding the payload
+-- @param offset 1-based offset of the parent byte within buffer
+-- @param cachedName pass the previous name to skip its decode (see
+-- readStringOrOpts); nil decodes it fresh
+-- @return field, or nil when the entry is shorter than parent + type + one
+-- name byte (the caller should still drop it from its queue)
+function Params.decodeEntry(field, fieldId, buffer, offset, cachedName)
+ -- Need at least parent + type + one name byte for the entry to be usable
+ if #buffer <= offset + 2 then
+ return nil
+ end
+ field.id = fieldId
+ field.parent = (buffer[offset] ~= 0) and buffer[offset] or nil
+ field.type = bit32.band(buffer[offset + 1], 0x7f)
+ field.hidden = bit32.btest(buffer[offset + 1], 0x80) or nil
+ field.name, offset = Params.readStringOrOpts(buffer, offset + 2, cachedName)
+ local load = handlers[field.type + 1]
+ if load then
+ load(field, buffer, offset)
+ end
+ if field.min == 0 then
+ field.min = nil
+ end
+ if field.max == 0 then
+ field.max = nil
+ end
+ return field
+end
+
+-- ============================================================================
+-- Frame encoders
+--
+-- Every encoder returns (frameType, payload) for the caller to push --
+-- crsf.push(Params.encodeRead(...)) -- so encoding stays free of transport.
+-- deviceId is the target device, handsetId the reply-to address. Wire
+-- layouts match the tables in CRSFParameters.h.
+-- ============================================================================
+
+--- Encode a request for one chunk of a field's PARAMETER_SETTINGS_ENTRY.
+-- Starts a new request cycle on rx: clears rx.done, and carries rx.chunk so
+-- follow-up reads of a chunked entry continue where reassemble() left off
+-- (0 requests a fresh entry).
+-- @param rx the reassembly-state table
+-- @param deviceId the target device address
+-- @param handsetId the reply-to address
+-- @param fieldId the field id to read
+-- @return frameType, payload
+function Params.encodeRead(rx, deviceId, handsetId, fieldId)
+ rx.done = nil
+ return crsf.CONST.FRAMETYPE_PARAMETER_READ, { deviceId, handsetId, fieldId, rx.chunk }
+end
+
+--- Encode a PARAMETER_WRITE carrying a field's integer value, big-endian at
+-- the field's width. field.size < 0 marks a signed field |size| bytes wide
+-- (decodeEntry's convention); negative values are re-encoded as two's
+-- complement. A missing size means 1 byte.
+-- @param deviceId the target device address
+-- @param handsetId the reply-to address
+-- @param field table with id, value and optional size
+-- @return frameType, payload
+function Params.encodeWriteInt(deviceId, handsetId, 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 = { deviceId, handsetId, field.id }
+ for i = size - 1, 0, -1 do
+ frame[#frame + 1] = bit32.rshift(value, 8 * i) % 256
+ end
+ return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame
+end
+
+--- Encode a PARAMETER_WRITE carrying a field's string value, clamped to
+-- field.maxlen (default 32), inner NULs stripped, null-terminated.
+-- @param deviceId the target device address
+-- @param handsetId the reply-to address
+-- @param field table with id, value and optional maxlen
+-- @return frameType, payload
+function Params.encodeWriteString(deviceId, handsetId, field)
+ local frame = { deviceId, 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
+ return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, frame
+end
+
+--- Encode a command-step PARAMETER_WRITE: one byte from the commandStep_e
+-- machine (crsf.CONST.CMD_CLICK / CMD_CONFIRMED / CMD_CANCEL / CMD_QUERY).
+-- @param deviceId the target device address
+-- @param handsetId the reply-to address
+-- @param fieldId the command field's id
+-- @param step the command step byte
+-- @return frameType, payload
+function Params.encodeCommandStep(deviceId, handsetId, fieldId, step)
+ return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, fieldId, step }
+end
+
+-- Pseudo-field id: a PARAMETER_WRITE to this id calls supressCriticalErrors()
+-- in TXModuleEndpoint.cpp (the firmware matches the bare 0x2E literal).
+local FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E
+
+--- Encode the write that asks the module to stop reporting its critical
+-- error flags (the bits above crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD in
+-- the ELRS status byte).
+-- @param deviceId the target device address
+-- @param handsetId the reply-to address
+-- @return frameType, payload
+function Params.encodeSuppressCriticalErrors(deviceId, handsetId)
+ return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, FIELD_ID_SUPPRESS_CRITICAL_ERRORS, 0 }
+end
+
+-- ============================================================================
+-- Return codec table
+-- ============================================================================
+
+return Params
diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua
new file mode 100644
index 0000000..ff007d6
--- /dev/null
+++ b/src/SCRIPTS/ELRS/crsf_session.lua
@@ -0,0 +1,740 @@
+---------------------------------------------------------------------------
+-- CRSF Parameter Session --
+-- --
+-- A stateful CRSF parameter client: owns the field store, the load --
+-- queue and its retry scheduler, the write queue and its pacing, the --
+-- command state machine, and optionally device discovery, link status --
+-- and ELRS 1.x detection. Mechanism lives here; policy -- what to load, --
+-- when to write, how to render -- stays with the caller, which reacts --
+-- through the store queries and the onFieldUpdate / onDeviceUpdate --
+-- callbacks. --
+-- --
+-- Multiple instances are real: the config tool and every VTX Admin --
+-- widget instance each own one. Methods live on the shared metatable, --
+-- so an instance costs one table plus its opts callbacks. --
+-- --
+-- Two receive models converge on _onFrame: a standalone tool drains --
+-- the destructive pop queue itself (drain()), widgets register on the --
+-- crsf.lua fan-out once (attachBus()) and crsf:poll() feeds them. --
+-- --
+-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf,params). --
+-- Returns the CRSFSession class; construct with CRSFSession.new(opts). --
+---------------------------------------------------------------------------
+
+local crsf, params = ...
+
+-- Scheduler constants (ticks, 10 ms each)
+local STATUS_PERIOD = 100 -- link-status cadence (1 s)
+local PING_PERIOD = 100 -- discovery ping cadence while no device answered
+local WRITE_SPACING = 5 -- minimum gap between parameter writes (50 ms)
+local WRITE_SETTLE = 20 -- post-write quiet time before the next read
+local CANCEL_GRACE = 200 -- wait for CMD_IDLE after a requested cancel (2 s)
+
+local CRSFSession = {}
+CRSFSession.__index = CRSFSession
+
+--- Create a session.
+-- @param opts table with:
+-- deviceId target device address (default ADDRESS_TX)
+-- handsetId reply-to address (default ADDRESS_HANDSET_ELRS)
+-- responseTimeout fixed read-retry deadline in ticks; when omitted it is
+-- derived per device: 50 for the local ELRS TX, 500 for
+-- remote devices relayed over the air link
+-- acceptUnsolicited true accepts any field from deviceId (passive fan-out:
+-- sibling instances see every response); default strict,
+-- accepting only the answer the session is waiting for
+-- discovery maintain .devices from DEVICE_INFO + ping cadence
+-- trackStatus keep .status current from ELRS_STATUS (1 Hz cadence)
+-- detectV1 watch inbound PARAMETER_WRITE for the ELRS 1.x
+-- signature, latching .v1Detected
+-- preload once the root load completes, queue every unloaded
+-- subfolder child for background loading
+-- onFieldUpdate function(field) called after each decoded entry
+-- onDeviceUpdate function(device, isNew) called after each DEVICE_INFO
+function CRSFSession.new(opts)
+ opts = opts or {}
+ return setmetatable({
+ -- Public facts
+ deviceId = opts.deviceId or crsf.CONST.ADDRESS_TX,
+ handsetId = opts.handsetId or crsf.CONST.ADDRESS_HANDSET_ELRS,
+ deviceName = nil,
+ isElrsTx = nil,
+ fieldsCount = 0,
+ devices = {},
+ command = nil, -- the command field driving the active popup
+ -- Link status; table identity is stable, only keys change
+ status = { flags = 0, warning = "" },
+ -- Read-and-clear flags for the app
+ fieldHiddenChanged = nil,
+ v1Detected = nil,
+ -- Reassembly state (crsf_params.lua manages it; rx.chunk is readable)
+ rx = { chunk = 0, expect = -1 },
+
+ -- Options
+ _acceptUnsolicited = opts.acceptUnsolicited,
+ _discovery = opts.discovery,
+ _trackStatus = opts.trackStatus,
+ _detectV1 = opts.detectV1,
+ _preload = opts.preload,
+ _respTimeout = opts.responseTimeout,
+ _onFieldUpdate = opts.onFieldUpdate,
+ _onDeviceUpdate = opts.onDeviceUpdate,
+
+ -- Field store: id -> field table, get-or-create on first entry so a
+ -- field's table identity is stable across reloads (UI closures hold it)
+ _fields = {},
+
+ -- Load queue (LIFO) and the five scheduler deadlines
+ _loadQueue = {},
+ _nextReadAt = 0,
+ _nextQueryAt = 0,
+ _nextStatusAt = 0,
+ _nextPingAt = 0,
+ _lastWriteAt = 0,
+
+ -- Write queue: encoded frames between head and tail, paced by tick()
+ _writeQueue = {},
+ _writeHead = 1,
+ _writeTail = 0,
+
+ -- Bounded best-effort refresh slot (refreshField)
+ _refreshId = nil,
+ _refreshAt = 0,
+ _refreshLeft = 0,
+ _refreshAttempts = 0,
+
+ _hadTelemetry = false,
+ _preloadArmed = nil,
+ _preloading = nil,
+ }, CRSFSession)
+end
+
+-- Read-retry deadline for PARAMETER_READ: a fixed opts.responseTimeout wins,
+-- otherwise 0.5 s for the local TX module, 5 s for remote devices relayed
+-- over the air link.
+function CRSFSession:_responseTimeout()
+ if self._respTimeout then
+ return self._respTimeout
+ end
+ return self.isElrsTx and 50 or 500
+end
+
+-- ============================================================================
+-- Lifecycle
+-- ============================================================================
+
+--- Point the session at a device (a .devices entry) and reload its fields.
+-- @return true when the device changed, false when nothing needed doing
+function CRSFSession:setDevice(device)
+ if not device then
+ return false
+ end
+ if self.deviceId == device.id and self.fieldsCount == device.fieldCount then
+ return false
+ end
+
+ self.deviceId = device.id
+ self.deviceName = device.name
+ self.fieldsCount = device.fieldCount
+ self.isElrsTx = device.isElrs and device.id == crsf.CONST.ADDRESS_TX or nil
+ self.handsetId = self.isElrsTx and crsf.CONST.ADDRESS_HANDSET_ELRS or crsf.CONST.ADDRESS_HANDSET
+ local st = self.status
+ st.flags = 0
+ st.connected = nil
+ st.modelMismatch = nil
+ st.criticalError = nil
+
+ self:reloadAll()
+ return true
+end
+
+--- Drain the destructive pop queue into the session (pull model, for a
+-- standalone tool that owns the queue). Widgets use attachBus() instead.
+function CRSFSession:drain()
+ local command, data
+ repeat
+ command, data = crsf.pop()
+ if command then
+ self:_onFrame(command, data)
+ end
+ until command == nil
+end
+
+--- Register the session's frame handlers on the crsf.lua fan-out (push
+-- model, for widgets). Call once; crsf:poll() then feeds the session.
+function CRSFSession:attachBus()
+ crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, function(data)
+ self:_onEntry(data)
+ end)
+ if self._discovery then
+ crsf:registerHandler(crsf.CONST.FRAMETYPE_DEVICE_INFO, function(data)
+ self:_onDeviceInfo(data)
+ end)
+ end
+ if self._trackStatus then
+ crsf:registerHandler(crsf.CONST.FRAMETYPE_ELRS_STATUS, function(data)
+ self:_onStatus(data)
+ end)
+ end
+ if self._detectV1 then
+ crsf:registerHandler(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, function(data)
+ self:_onWrite(data)
+ end)
+ end
+end
+
+-- ============================================================================
+-- Frame handlers
+-- ============================================================================
+
+function CRSFSession:_onFrame(command, data)
+ if command == crsf.CONST.FRAMETYPE_PARAMETER_SETTINGS_ENTRY then
+ self:_onEntry(data)
+ elseif command == crsf.CONST.FRAMETYPE_DEVICE_INFO then
+ if self._discovery then
+ self:_onDeviceInfo(data)
+ end
+ elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then
+ if self._trackStatus then
+ self:_onStatus(data)
+ end
+ elseif command == crsf.CONST.FRAMETYPE_PARAMETER_WRITE then
+ if self._detectV1 then
+ self:_onWrite(data)
+ end
+ end
+end
+
+function CRSFSession:_onDeviceInfo(data)
+ local info = crsf:decodeDeviceInfo(data)
+ if not info then
+ return
+ end
+ local device = self:getDevice(info.id)
+ local isNew = device == nil
+ if isNew then
+ device = { id = info.id }
+ self.devices[#self.devices + 1] = device
+ end
+ device.name = info.name
+ device.fieldCount = info.fieldCount
+ device.isElrs = info.isElrs
+ if self._onDeviceUpdate then
+ self._onDeviceUpdate(device, isNew)
+ end
+end
+
+function CRSFSession:_onStatus(data)
+ local status = crsf:decodeElrsStatus(data)
+ if not status then
+ return
+ end
+ if status.id ~= self.deviceId then
+ -- A foreign device's status while we buffer chunks means our entry
+ -- stream was interrupted: abandon it
+ params.resetChunks(self.rx)
+ return
+ end
+ local st = self.status
+ st.lostPackets = status.lostPackets
+ st.receivedPackets = status.receivedPackets
+ st.flags = status.flags
+ st.connected = status.connected
+ st.modelMismatch = status.modelMismatch
+ st.criticalError = status.criticalError
+ st.warning = status.warning
+end
+
+function CRSFSession:_onWrite(data)
+ if crsf:isElrsV1Frame(data) then
+ self.v1Detected = true
+ end
+end
+
+function CRSFSession:_onEntry(data)
+ local expectedId
+ if self._acceptUnsolicited then
+ expectedId = data[3]
+ else
+ expectedId = (self.command and self.command.id) or self._loadQueue[#self._loadQueue] or self._refreshId
+ end
+ local fieldId, buffer, offset = params.reassemble(self.rx, self.deviceId, data, expectedId)
+ if not fieldId then
+ return
+ end
+ local now = getTime()
+ if not buffer then
+ -- Chunk consumed: hurry the follow-up read, which carries the updated
+ -- chunk index. A refresh slot answering mid-entry burns no attempt.
+ if self._loadQueue[#self._loadQueue] == fieldId then
+ self._nextReadAt = 0
+ elseif self._refreshId == fieldId then
+ self._refreshAt = now
+ self._refreshLeft = self._refreshAttempts
+ elseif self.command then
+ self._nextQueryAt = now + (self.command.timeout or 100)
+ end
+ return
+ end
+
+ -- Entry complete: it settles the queue head only when it answers it -- a
+ -- command status elicited while loads are pending must not pop an
+ -- unrelated field
+ local answeredHead = self._loadQueue[#self._loadQueue] == fieldId
+ if answeredHead then
+ self._loadQueue[#self._loadQueue] = nil
+ end
+ if self._refreshId == fieldId then
+ self._refreshId = nil
+ end
+
+ local field = self._fields[fieldId]
+ if not field then
+ field = {}
+ self._fields[fieldId] = field
+ end
+
+ -- Hidden-bit changes rebuild the UI's cached list of visible fields, so
+ -- track it across the decode.
+ local wasHidden = field.hidden
+ -- Passing the old name makes the decoder skip its read and reuse that
+ -- string. In strict mode any name change is flagged first
+ -- (nameStale/reloading). A passive fan-out session also decodes entries
+ -- it never asked for, so no flag can cover a change -- but only folder
+ -- names embed values (ExpressLRS rewrites them on writes); every other
+ -- name is static, and caching it keeps the fan-out path cheap enough for
+ -- many sessions sharing one bus.
+ local cachedName
+ if not self._acceptUnsolicited then
+ cachedName = (not field.nameStale and not field.reloading) and field.name or nil
+ elseif field.type ~= crsf.CONST.FIELD_FOLDER then
+ cachedName = field.name
+ end
+ if params.decodeEntry(field, fieldId, buffer, offset, cachedName) then
+ field.nameStale = nil
+ field.reloading = nil
+ if field.hidden ~= wasHidden then
+ self.fieldHiddenChanged = true
+ end
+
+ if field.type == crsf.CONST.FIELD_COMMAND and field.status == crsf.CONST.CMD_IDLE and self.command == field then
+ -- The active command just finished (or was cancelled): re-read its
+ -- same-level fields so the current page reflects any values the
+ -- command changed, and dismiss the popup. The guard limits both to
+ -- the active command -- routine loads of idle command fields while
+ -- browsing must not trigger either.
+ self:_reloadRelated(field)
+ self.command = nil
+ end
+
+ -- Auto-queue children for the root folder and during preloading -- but
+ -- only off the answer to our own read: under the fan-out, sessions also
+ -- see every sibling's root answers, and re-queueing the children each
+ -- time would multiply the load traffic by the instance count
+ if
+ answeredHead
+ and field.type == crsf.CONST.FIELD_FOLDER
+ and field.children
+ and (fieldId == 0 or self._preloading)
+ then
+ for i = #field.children, 1, -1 do
+ self._loadQueue[#self._loadQueue + 1] = field.children[i]
+ end
+ end
+
+ if self._onFieldUpdate then
+ self._onFieldUpdate(field)
+ end
+ end
+
+ if self._loadQueue[1] then
+ self._nextReadAt = 0
+ else
+ if self.command then
+ self._nextQueryAt = now + (self.command.timeout or 100)
+ end
+ if self._preloadArmed and self:isFolderLoaded(nil) then
+ self._preloadArmed = nil
+ self:preloadAll()
+ end
+ end
+end
+
+-- ============================================================================
+-- Store queries
+-- ============================================================================
+
+--- The field table for an id, or nil while it is unknown.
+function CRSFSession:field(id)
+ return self._fields[id]
+end
+
+--- The device table for an address, or nil (opts.discovery fills .devices).
+function CRSFSession:getDevice(id)
+ for _, device in ipairs(self.devices) do
+ if device.id == id then
+ return device
+ end
+ end
+end
+
+--- Loaded children of a folder, in wire order. folderId nil means root.
+function CRSFSession:fieldsInFolder(folderId)
+ local folder = self._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 = self._fields[childId]
+ if child and child.name then
+ result[#result + 1] = child
+ end
+ end
+ return result
+end
+
+--- True when every child of a folder is loaded. folderId nil means root.
+function CRSFSession:isFolderLoaded(folderId)
+ local folder = self._fields[folderId or 0]
+ if not folder or not folder.children then
+ return false
+ end
+ for _, childId in ipairs(folder.children) do
+ local child = self._fields[childId]
+ if not child or not child.name or child.nameStale then
+ return false
+ end
+ end
+ return true
+end
+
+--- Load progress for a folder's children as (loaded, total), or nil while
+-- the folder or its children list is unknown. folderId nil means root.
+function CRSFSession:folderLoadProgress(folderId)
+ local folder = self._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 = self._fields[childId]
+ if child and child.name and not child.reloading then
+ loaded = loaded + 1
+ end
+ end
+ return loaded, total
+end
+
+--- True while reads are queued.
+function CRSFSession:isLoading()
+ return self._loadQueue[1] ~= nil
+end
+
+--- True while a multi-chunk entry is mid-reassembly.
+function CRSFSession:isReceivingChunks()
+ return self.rx.chunk > 0
+end
+
+--- True while background preloading (opts.preload) is filling the store.
+function CRSFSession:isPreloading()
+ return self._preloading ~= nil
+end
+
+--- True while writes are waiting in the paced write queue.
+function CRSFSession:isWriting()
+ return self._writeHead <= self._writeTail
+end
+
+-- ============================================================================
+-- Loading
+-- ============================================================================
+
+--- Forget every field and reload from the root folder. Its response carries
+-- the child ids, which auto-queue; subfolder children load on demand via
+-- loadFolder() or in the background via opts.preload.
+function CRSFSession:reloadAll()
+ self._fields = {}
+ self._loadQueue = { 0 }
+ self._nextReadAt = 0
+ self._preloadArmed = self._preload
+ self._preloading = nil
+ params.resetChunks(self.rx)
+end
+
+--- Re-read one field now.
+function CRSFSession:reloadField(field)
+ self._nextReadAt = 0
+ params.resetChunks(self.rx)
+ self._loadQueue[#self._loadQueue + 1] = field.id
+end
+
+--- Queue a folder's unloaded children.
+function CRSFSession:loadFolder(folderId)
+ local folder = self._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 = self._fields[childId]
+ if not (child and child.name) then
+ self._loadQueue[#self._loadQueue + 1] = childId
+ end
+ end
+ if self._loadQueue[1] then
+ self._nextReadAt = 0
+ end
+end
+
+--- Queue every unloaded subfolder child for background loading.
+function CRSFSession:preloadAll()
+ self._preloading = true
+ for id = 1, self.fieldsCount do
+ local field = self._fields[id]
+ if field and field.type == crsf.CONST.FIELD_FOLDER and field.children then
+ for j = #field.children, 1, -1 do
+ local childId = field.children[j]
+ local child = self._fields[childId]
+ if not (child and child.name) then
+ self._loadQueue[#self._loadQueue + 1] = childId
+ end
+ end
+ end
+ end
+ if self._loadQueue[1] then
+ self._nextReadAt = 0
+ end
+end
+
+--- Arm the bounded best-effort refresh slot: read fieldId `delay` ticks from
+-- now, retrying at most `attempts` times if unanswered. Event-driven, never
+-- periodic -- use it when something else can have changed the device (our
+-- own writes, resume from suspension), not as a poll.
+function CRSFSession:refreshField(fieldId, delay, attempts)
+ self._refreshId = fieldId
+ self._refreshAt = getTime() + (delay or 0)
+ self._refreshAttempts = attempts or 3
+ self._refreshLeft = self._refreshAttempts
+ -- A fresh read must not inherit chunk state from an interrupted one
+ params.resetChunks(self.rx)
+end
+
+-- ============================================================================
+-- Writing
+-- ============================================================================
+
+-- Re-read what a value change can have altered: the parent folder (its name
+-- may embed values) and every non-folder sibling (CRSF parameters on one
+-- level are routinely interdependent -- option lists shrink, fields hide).
+function CRSFSession:_reloadRelated(field)
+ if field.parent and self._fields[field.parent] then
+ self._fields[field.parent].nameStale = true
+ self._loadQueue[#self._loadQueue + 1] = field.parent
+ end
+
+ for fieldId = self.fieldsCount, 1, -1 do
+ local sibling = self._fields[fieldId]
+ if sibling and fieldId ~= field.id and sibling.parent == field.parent then
+ local siblingType = sibling.type or 99
+ if siblingType < crsf.CONST.FIELD_FOLDER or siblingType == crsf.CONST.FIELD_INFO then
+ sibling.dirty = true
+ sibling.reloading = true
+ self._loadQueue[#self._loadQueue + 1] = fieldId
+ end
+ end
+ end
+
+ field.dirty = true
+ field.reloading = true
+ self._loadQueue[#self._loadQueue + 1] = field.id
+ self:_afterWrite()
+end
+
+-- Post-write settle: give the device WRITE_SETTLE to apply the change
+-- before the re-reads go out, and keep the next link-status request from
+-- landing inside that window.
+function CRSFSession:_afterWrite()
+ local now = getTime()
+ self._nextReadAt = now + WRITE_SETTLE
+ local statusAt = now + WRITE_SETTLE + STATUS_PERIOD
+ if self._nextStatusAt < statusAt then
+ self._nextStatusAt = statusAt
+ end
+end
+
+--- Write a field's current value to the device: encodes by field type,
+-- sends immediately when the wire is idle (every push replaces one
+-- RC-channels frame, so bursts are paced by tick()). In strict mode the
+-- write also re-reads its related fields; a passive fan-out session owns
+-- its read-back policy (refreshField).
+function CRSFSession:writeField(field)
+ local frameType, payload
+ if field.type == crsf.CONST.FIELD_STRING then
+ frameType, payload = params.encodeWriteString(self.deviceId, self.handsetId, field)
+ else
+ frameType, payload = params.encodeWriteInt(self.deviceId, self.handsetId, field)
+ end
+
+ local now = getTime()
+ if self._writeHead > self._writeTail and now - self._lastWriteAt >= WRITE_SPACING then
+ crsf.push(frameType, payload)
+ self._lastWriteAt = now
+ else
+ self._writeTail = self._writeTail + 1
+ self._writeQueue[self._writeTail] = { frameType, payload }
+ end
+
+ if not self._acceptUnsolicited then
+ self:_reloadRelated(field)
+ end
+end
+
+-- ============================================================================
+-- Commands
+-- ============================================================================
+
+--- Click a command field. When the device accepts, session.command holds the
+-- field until it reports CMD_IDLE (or the command is cancelled); the UI
+-- renders its popup from session.command.status/info.
+function CRSFSession:execCommand(field)
+ self:reloadField(field)
+ if field.status ~= nil and field.status < crsf.CONST.CMD_CONFIRMED then
+ field.status = crsf.CONST.CMD_CLICK
+ crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, field.id, crsf.CONST.CMD_CLICK))
+ self.command = field
+ self._nextQueryAt = getTime() + (field.timeout or 100)
+ end
+end
+
+--- Answer the device's CMD_ASKCONFIRM.
+function CRSFSession:confirmCommand()
+ if self.command then
+ crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CONFIRMED))
+ self._nextQueryAt = getTime() + (self.command.timeout or 100)
+ self.command.status = crsf.CONST.CMD_CONFIRMED
+ end
+end
+
+--- Cancel and dismiss: sends CMD_CANCEL and drops the popup immediately.
+function CRSFSession:cancelCommand()
+ if self.command then
+ crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CANCEL))
+ self.command = nil
+ end
+end
+
+--- Cancel but keep the popup: sends CMD_CANCEL and waits CANCEL_GRACE for
+-- the device to report CMD_IDLE, which dismisses the popup through the
+-- entry decode. Used while no dialog is on screen yet (e.g. right after
+-- CMD_CLICK), so the UI keeps tracking the device's actual command state.
+function CRSFSession:requestCancelCommand()
+ if self.command then
+ crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_CANCEL))
+ self._nextQueryAt = getTime() + CANCEL_GRACE
+ end
+end
+
+-- ============================================================================
+-- Status
+-- ============================================================================
+
+--- Clear the ELRS critical-error banner: optimistic local clear plus the
+-- suppress write the module acts on.
+function CRSFSession:suppressCriticalErrors()
+ self.status.flags = 0
+ crsf.push(params.encodeSuppressCriticalErrors(self.deviceId, self.handsetId))
+end
+
+-- ============================================================================
+-- Scheduler
+-- ============================================================================
+
+--- Send what is due. At most one parameter frame per call, strict priority:
+-- command keep-alive > write drain > link-status > reads (refresh slot,
+-- then load-queue head). While a command runs it owns the wire -- reads
+-- starve by design, and the field data rides its CMD_QUERY answers.
+-- Discovery pings sit outside that chain: they cost no parameter traffic.
+function CRSFSession:tick()
+ local now = getTime()
+
+ if self._discovery then
+ -- Ping on telemetry transition (the answering device may have changed)
+ local connected = self.status.connected
+ if connected and not self._hadTelemetry then
+ crsf:pingDevices()
+ end
+ self._hadTelemetry = connected
+ -- Periodic ping for initial device discovery
+ if #self.devices == 0 and now > self._nextPingAt then
+ crsf:pingDevices()
+ self._nextPingAt = now + PING_PERIOD
+ end
+ end
+
+ if self.command then
+ if now > self._nextQueryAt and self.command.status ~= crsf.CONST.CMD_ASKCONFIRM then
+ crsf.push(params.encodeCommandStep(self.deviceId, self.handsetId, self.command.id, crsf.CONST.CMD_QUERY))
+ self._nextQueryAt = now + (self.command.timeout or 100)
+ end
+ return
+ end
+
+ if self._writeHead <= self._writeTail then
+ if now - self._lastWriteAt >= WRITE_SPACING then
+ local write = self._writeQueue[self._writeHead]
+ self._writeQueue[self._writeHead] = nil
+ self._writeHead = self._writeHead + 1
+ if self._writeHead > self._writeTail then
+ self._writeHead = 1
+ self._writeTail = 0
+ end
+ crsf.push(write[1], write[2])
+ self._lastWriteAt = now
+ end
+ return
+ end
+
+ if self._trackStatus and now > self._nextStatusAt then
+ if self.isElrsTx then
+ -- isElrsTx guarantees deviceId/handsetId are ADDRESS_TX and
+ -- ADDRESS_HANDSET_ELRS here (see setDevice), the addressing
+ -- requestElrsStatus() hardcodes.
+ crsf:requestElrsStatus()
+ else
+ self.status.receivedPackets = nil
+ self.status.lostPackets = nil
+ end
+ self._nextStatusAt = now + STATUS_PERIOD
+ return
+ end
+
+ if self._refreshId then
+ if now >= self._refreshAt then
+ if self._refreshLeft > 0 then
+ self._refreshLeft = self._refreshLeft - 1
+ self._refreshAt = now + self:_responseTimeout()
+ crsf.push(params.encodeRead(self.rx, self.deviceId, self.handsetId, self._refreshId))
+ else
+ self._refreshId = nil
+ end
+ end
+ return
+ end
+
+ if now > self._nextReadAt then
+ if self._loadQueue[1] then
+ crsf.push(params.encodeRead(self.rx, self.deviceId, self.handsetId, self._loadQueue[#self._loadQueue]))
+ self._nextReadAt = now + self:_responseTimeout()
+ else
+ self._preloading = nil
+ end
+ end
+end
+
+-- ============================================================================
+-- Return class
+-- ============================================================================
+
+return CRSFSession
diff --git a/src/SCRIPTS/ELRS/file_storage.lua b/src/SCRIPTS/ELRS/file_storage.lua
new file mode 100644
index 0000000..1c629d7
--- /dev/null
+++ b/src/SCRIPTS/ELRS/file_storage.lua
@@ -0,0 +1,79 @@
+---------------------------------------------------------------------------
+-- Key=Value File Storage --
+-- Loaded via loadScript() with no arguments; returns FileStorage. --
+-- --
+-- Generic line-oriented "key=value" persistence for script settings. --
+-- Knows nothing about any schema: keys and values are plain strings, --
+-- typing and defaults belong to the caller. --
+---------------------------------------------------------------------------
+
+local FileStorage = {}
+
+-- A settings file is a handful of short lines; one bounded read keeps the
+-- parser simple and caps a corrupt file's blast radius.
+local READ_MAX = 512
+
+--- 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
+
+--- Read a key=value file into a table of strings, later duplicates of a
+--- key winning. Returns nil when the file cannot be opened.
+function FileStorage.read(path)
+ local f = io.open(path, "r")
+ if not f then
+ return nil
+ end
+ local data = io.read(f, READ_MAX)
+ io.close(f)
+ local kv = {}
+ if not data or #data == 0 then
+ return kv
+ end
+ -- 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 and val then
+ kv[key] = val
+ end
+ end
+ return kv
+end
+
+--- Write one "key=value" line per entry of keys, in that order. Values may
+--- be strings or numbers; keys absent from values are skipped. Returns true
+--- on success, nil when the file cannot be opened for writing.
+function FileStorage.write(path, keys, values)
+ local f = io.open(path, "w")
+ if not f then
+ return nil
+ end
+ local lines = {}
+ for i = 1, #keys do
+ local val = values[keys[i]]
+ if val ~= nil then
+ lines[#lines + 1] = table.concat({ keys[i], "=", val, "\n" })
+ end
+ end
+ io.write(f, table.concat(lines))
+ io.close(f)
+ return true
+end
+
+return FileStorage
diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua
new file mode 100644
index 0000000..1e69ad0
--- /dev/null
+++ b/src/SCRIPTS/ELRS/shim.lua
@@ -0,0 +1,80 @@
+---------------------------------------------------------------------------
+-- B&W Compatibility Layer --
+-- --
+-- Polyfills for standard Lua library functions missing on B&W radios --
+-- (table.concat, table.remove) and shared helpers (byte-array decoding). --
+-- --
+-- 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
+
+-- ============================================================================
+-- Byte array -> string
+-- Stands in for string.char(table.unpack(t)), which needs the table library.
+-- Deliberately iterative: a pure-Lua unpack has to recurse once per element
+-- (and `return t[i], f(...)` is not a tail call, so it cannot be optimised
+-- away), which is not something to hand EdgeTX's Lua stack.
+-- ============================================================================
+
+function shim.charsToString(t, i, j)
+ local parts = {}
+ for k = i or 1, j or #t do
+ parts[#parts + 1] = string.char(t[k])
+ end
+ return shim.tableConcat(parts)
+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..6fab531
--- /dev/null
+++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua
@@ -0,0 +1,206 @@
+-- TNS|ExpressLRS|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.11.6+/2.12.1+) #
+---- #########################################################################
+
+local VERSION = "r3"
+local useLvgl = (lvgl ~= nil)
+
+-- ============================================================================
+-- Load shared modules
+-- ============================================================================
+
+local crsf = loadScript("/SCRIPTS/ELRS/crsf.lua")()
+local params = loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf)
+local CRSFSession = loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf, params)
+local Navigation = loadScript("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua")()
+
+-- ============================================================================
+-- App Module: business logic between the session and the UI
+-- ============================================================================
+
+local App = {
+ -- Tool-internal pseudo field types for the synthetic device rows in the
+ -- "Other Devices" list. Values sit above the wire range: the type byte is
+ -- masked with 0x7f at parse, so a real field type can never exceed 127 --
+ -- unlike 15/16, which the previous numbering used and which shadow
+ -- CRSF_VTX (0x0F) on the wire.
+ DEVICE = 128,
+ DEVICE_FOLDER = 129,
+
+ crsfModuleChecked = false,
+ crsfModuleFound = false,
+ shouldExit = false,
+}
+
+local UI
+local session
+
+function App.checkCrsfModule()
+ if App.crsfModuleChecked then
+ return App.crsfModuleFound
+ end
+ App.crsfModuleChecked = true
+ App.crsfModuleFound = crsf.hasCrsfModule()
+ return App.crsfModuleFound
+end
+
+-- Returns true if device was set, false if no change needed.
+function App.loadDevice(device)
+ if session:setDevice(device) then
+ Navigation.reset()
+ return true
+ end
+ return false
+end
+
+-- Returns true if device was switched.
+function App.switchDevice(deviceId, viewState)
+ local device = session:getDevice(deviceId)
+ if not device then
+ return false
+ end
+ local prevDeviceId = session.deviceId
+ if session: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)
+ session:loadFolder(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 session.deviceId ~= crsf.CONST.ADDRESS_TX then
+ local txDevice = session:getDevice(crsf.CONST.ADDRESS_TX)
+ if txDevice then
+ App.loadDevice(txDevice)
+ end
+ else
+ session:reloadAll()
+ end
+ crsf:pingDevices()
+end
+
+-- ============================================================================
+-- Session: the tool talks to one device at a time, tracking it fully
+-- ============================================================================
+
+session = CRSFSession.new({
+ discovery = true,
+ trackStatus = true,
+ detectV1 = true,
+ preload = true,
+ onDeviceUpdate = function(device, isNew)
+ if device.id == session.deviceId and App.loadDevice(device) then
+ UI.onDeviceLoaded()
+ end
+ if isNew then
+ UI.onNewDevice()
+ end
+ end,
+})
+
+-- ============================================================================
+-- UI loading (deferred to init)
+-- ============================================================================
+
+-- Module table, forward-declared so init() can drop itself once it has run.
+local M = {}
+
+local function init()
+ local deps = {
+ App = App,
+ Navigation = Navigation,
+ session = session,
+ crsf = crsf,
+ 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()
+ -- The returned table stays on the standalone Lua stack and pins init(),
+ -- which holds VERSION and useLvgl as upvalues. Drop it.
+ M.init = nil
+end
+
+-- ============================================================================
+-- Run (shared orchestrator)
+-- ============================================================================
+
+local function run(event, touchState)
+ if event == nil then
+ return 2
+ end
+
+ -- UI-specific pre-checks (version check on both LVGL and BW paths)
+ if UI.preCheck then
+ local result = UI.preCheck(event)
+ 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
+
+ session:drain()
+ session:tick()
+
+ if session.v1Detected then
+ UI.handleUnsupported()
+ return 0
+ end
+
+ local currentFolder = Navigation.getCurrent()
+ local folderReady = session:isFolderLoaded(currentFolder)
+ if folderReady and not UI.folderWasReady then
+ collectgarbage("collect")
+ UI.invalidate()
+ end
+ UI.folderWasReady = folderReady
+
+ if session.fieldHiddenChanged then
+ session.fieldHiddenChanged = nil
+ UI.visibleFields = nil
+ end
+
+ UI.render(event, touchState)
+
+ if App.shouldExit then
+ return 2
+ end
+ return 0
+end
+
+-- ============================================================================
+-- Return
+-- ============================================================================
+
+M.init = init
+M.run = run
+M.useLvgl = useLvgl
+return M
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/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua
new file mode 100644
index 0000000..6fae5a7
--- /dev/null
+++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua
@@ -0,0 +1,625 @@
+---- #########################################################################
+---- # 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 session = deps.session
+local crsf = deps.crsf
+local VERSION = deps.VERSION
+
+local versionCheckResult = nil
+
+local function checkEdgeTxVersion()
+ local _ver, _radio, maj, minor, rev = getVersion()
+
+ if maj >= 3 then
+ return true
+ elseif maj == 2 and minor == 12 and rev >= 1 then
+ return true
+ elseif maj == 2 and minor == 11 and rev >= 6 then
+ return true
+ end
+
+ return false
+end
+
+-- ============================================================================
+-- 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 for 128x64; UI.init widens COL2 at 212px wide and
+ -- raises maxLineIndex at 96px tall
+ COL1 = 0,
+ COL2 = 70,
+ maxLineIndex = 6,
+ textSize = 8,
+ textYoffset = 3,
+
+ -- Redraw state
+ forceRedraw = true,
+ folderWasReady = false,
+ wasLoading = false,
+
+ -- Warning flashing
+ titleShowWarn = nil,
+ titleShowWarnTimeout = 100,
+
+ -- Warning dismissal (model mismatch)
+ warningDismissedAt = nil,
+
+ -- Command popup spinner
+ commandRunningIndicator = 1,
+}
+
+-- ============================================================================
+-- Interface: init
+-- ============================================================================
+
+function UI.init()
+ if LCD_W == 212 then
+ UI.COL2 = 110
+ end
+ if LCD_H == 96 then
+ UI.maxLineIndex = 9
+ end
+
+ versionCheckResult = checkEdgeTxVersion()
+end
+
+-- ============================================================================
+-- Interface: preCheck (version gate)
+-- ============================================================================
+
+function UI.preCheck(event)
+ if not versionCheckResult then
+ UI.drawAlert("Unsupported", {
+ "Requires EdgeTX:",
+ "- 2.11.6 or later",
+ "- 2.12.1 or later",
+ "- 3.0 or later",
+ })
+ if event == EVT_VIRTUAL_EXIT then
+ App.shouldExit = true
+ return 2
+ end
+ return 0
+ end
+ return nil
+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 = (session.status.flags > crsf.CONST.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 and time - UI.warningDismissedAt > 6000 then
+ UI.warningDismissedAt = nil
+ end
+
+ -- Model mismatch alert (full-screen, blocks normal rendering)
+ if session.status.modelMismatch and not UI.warningDismissedAt then
+ if event == EVT_VIRTUAL_ENTER then
+ 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 while the queue is loading, to show the progress bar, and once
+ -- more on the frame it empties. poll() pops the last entry before we get here,
+ -- so without the trailing edge the response that completes a reload never
+ -- reaches the screen and the page waits for the next event or warn tick.
+ local loading = session:isLoading()
+ if loading or UI.wasLoading then
+ UI.forceRedraw = true
+ end
+ UI.wasLoading = loading
+
+ -- Render: command popup or normal page
+ if session.command ~= 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 = session:getDevice(entry.prevDeviceId)
+ if prevDevice then
+ session: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(session.devices) do
+ if device.id ~= session.deviceId then
+ vf[#vf + 1] = { id = device.id, name = device.name, type = App.DEVICE }
+ end
+ end
+ else
+ local fields = session:fieldsInFolder(currentFolder)
+ for _, field in ipairs(fields) do
+ if not field.hidden then
+ vf[#vf + 1] = field
+ end
+ end
+
+ if currentFolder == nil and #session.devices > 1 and not Navigation.hasDeviceEntry() then
+ vf[#vf + 1] = { name = "Other Devices", type = App.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 <= crsf.CONST.FIELD_FLOAT then
+ min = field.min or 0
+ max = field.max or 0
+ step = (field.step or 1) * step
+ elseif field.type == crsf.CONST.FIELD_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()
+ 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[crsf.CONST.FIELD_UINT8] = fieldIntDisplay
+displayHandlers[crsf.CONST.FIELD_INT8] = fieldIntDisplay
+displayHandlers[crsf.CONST.FIELD_UINT16] = fieldIntDisplay
+displayHandlers[crsf.CONST.FIELD_INT16] = fieldIntDisplay
+displayHandlers[crsf.CONST.FIELD_FLOAT] = fieldFloatDisplay
+displayHandlers[crsf.CONST.FIELD_TEXT_SELECTION] = fieldTextSelDisplay
+displayHandlers[crsf.CONST.FIELD_STRING] = fieldStringDisplay
+displayHandlers[crsf.CONST.FIELD_INFO] = fieldStringDisplay
+displayHandlers[crsf.CONST.FIELD_FOLDER] = fieldFolderDisplay
+displayHandlers[crsf.CONST.FIELD_COMMAND] = fieldCommandDisplay
+displayHandlers[App.DEVICE] = fieldCommandDisplay
+displayHandlers[App.DEVICE_FOLDER] = fieldFolderDisplay
+
+-- ============================================================================
+-- Title bar drawing
+-- ============================================================================
+
+function UI.drawTitle()
+ local barHeight = 9
+ local goodBadPkt = ""
+ local status = session.status
+ if status.receivedPackets then
+ local state = status.connected and "C" or "-"
+ goodBadPkt = string.format("%u/%u %s", status.lostPackets, status.receivedPackets, state)
+ end
+
+ local loaded, total = session:folderLoadProgress(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, session.status.warning, INVERS)
+ else
+ lcd.drawText(UI.COL1, 1, session.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, session.status.warning)
+ lcd.drawText(LCD_W / 2, UI.textSize * 5, "[OK]", BLINK + INVERS + CENTER)
+end
+
+-- ============================================================================
+-- Event handling
+-- ============================================================================
+
+function UI.handleEvent(event)
+ 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
+ session:reloadField(field)
+ end
+ else
+ UI.handleBack()
+ end
+ elseif event == EVT_VIRTUAL_ENTER then
+ if session.status.flags > crsf.CONST.ELRS_FLAGS_WARNING_THRESHOLD then
+ session:suppressCriticalErrors()
+ 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 == crsf.CONST.FIELD_FOLDER then
+ UI.openFolder(field.id, field.name)
+ elseif ft == App.DEVICE_FOLDER then
+ UI.openFolder(Navigation.FOLDER_OTHER_DEVICES, "Other Devices")
+ elseif ft == App.DEVICE then
+ UI.switchDevice(field.id)
+ elseif ft == crsf.CONST.FIELD_COMMAND then
+ session:execCommand(field)
+ elseif not field.disabled and ft <= crsf.CONST.FIELD_TEXT_SELECTION then
+ UI.edit = not UI.edit
+ if not UI.edit then
+ session:writeField(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 session.status.flags > crsf.CONST.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 < crsf.CONST.FIELD_FOLDER or ft == crsf.CONST.FIELD_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)
+ local command = session.command
+ if event == EVT_VIRTUAL_EXIT then
+ local status = command.status
+ if status ~= crsf.CONST.CMD_ASKCONFIRM and status ~= crsf.CONST.CMD_EXECUTING then
+ -- No dialog is on screen yet (e.g. CMD_CLICK just went out): request the
+ -- cancel but keep the popup up until the device reports CMD_IDLE. The
+ -- dialog branches below handle their own cancel via popupConfirmation.
+ session:requestCancelCommand()
+ end
+ end
+
+ if command.status == crsf.CONST.CMD_ASKCONFIRM then
+ local result = popupConfirmation(command.info or "", "PRESS [OK] to confirm", event)
+ if result == "OK" then
+ session:confirmCommand()
+ elseif result == "CANCEL" then
+ session:cancelCommand()
+ end
+ elseif command.status == crsf.CONST.CMD_EXECUTING then
+ if not session:isReceivingChunks() then
+ UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1
+ end
+ local result = popupConfirmation(
+ (command.info or "") .. " [" .. string.sub("|/-\\", UI.commandRunningIndicator, UI.commandRunningIndicator) .. "]",
+ "Press [RTN] to exit",
+ event
+ )
+ if result == "CANCEL" then
+ session:cancelCommand()
+ 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..1622870
--- /dev/null
+++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua
@@ -0,0 +1,1113 @@
+---- #########################################################################
+---- # LVGL UI: Color LCD rendering, dialogs, command pages #
+---- # For color LCD radios with EdgeTX 2.12.1+ LVGL support #
+---- #########################################################################
+
+local deps = ...
+
+local App = deps.App
+local Navigation = deps.Navigation
+local session = deps.session
+local crsf = deps.crsf
+local VERSION = deps.VERSION
+
+-- ============================================================================
+-- UI state
+-- ============================================================================
+
+local UI = {
+ currentPage = nil,
+ uiBuilt = false,
+ folderWasReady = false,
+
+ -- Warning/command state (LVGL-specific). cmdLastStatus remembers which
+ -- command status the current dialog was built for, so a status change
+ -- swaps the dialog exactly once.
+ warningDismissedAt = nil,
+ warningDialog = nil,
+ commandDialog = nil,
+ cmdLastStatus = 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, getInfo, 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,
+ -- Prompt supplied by the caller as a getter so it refreshes each frame
+ text = getInfo,
+ },
+ {
+ 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, getInfo, 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,
+ },
+ {
+ -- Live status text the device sends back while the command runs.
+ -- Supplied by the caller as a getter so each CMD_QUERY poll response is shown.
+ type = lvgl.LABEL,
+ w = lvgl.PERCENT_SIZE + 100,
+ align = CENTER,
+ font = BOLD,
+ text = getInfo,
+ },
+ {
+ 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 == 12 and rev >= 1 then
+ return true
+ elseif maj == 2 and minor == 11 and rev >= 6 then
+ 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.11.6 or later" },
+ { type = "label", text = "- 2.12.1 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
+
+-- ============================================================================
+-- Interface: init
+-- ============================================================================
+
+function UI.init()
+ versionCheckResult = checkEdgeTxVersion()
+end
+
+-- ============================================================================
+-- Interface: preCheck (version gate)
+-- ============================================================================
+
+function UI.preCheck(_event)
+ if not versionCheckResult 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 = session:getDevice(entry.prevDeviceId)
+ if prevDevice then
+ session:setDevice(prevDevice)
+ end
+ end
+ UI.invalidate()
+ end
+end
+
+-- ============================================================================
+-- Command popup handling
+-- ============================================================================
+
+local function onCommandCancel()
+ session:cancelCommand()
+ UI.commandDialog = nil
+ UI.invalidate()
+end
+
+local function handleCommandPopup()
+ local command = session.command
+ if not command then
+ if UI.commandDialog then
+ UI.commandDialog = nil
+ UI.invalidate()
+ end
+ UI.cmdLastStatus = nil
+ return
+ end
+
+ if command.status == crsf.CONST.CMD_ASKCONFIRM then
+ if not UI.commandDialog or UI.cmdLastStatus ~= crsf.CONST.CMD_ASKCONFIRM then
+ UI.commandDialog = CommandPage.showConfirm(command.name, function()
+ return command.info or ""
+ end, function()
+ session:confirmCommand()
+ end, onCommandCancel)
+ end
+ elseif command.status == crsf.CONST.CMD_EXECUTING then
+ if not UI.commandDialog or UI.cmdLastStatus ~= crsf.CONST.CMD_EXECUTING then
+ UI.commandDialog = CommandPage.showExecuting(command.name, function()
+ return command.info or ""
+ end, onCommandCancel)
+ end
+ end
+ UI.cmdLastStatus = command.status
+end
+
+-- ============================================================================
+-- Warning handling
+-- ============================================================================
+
+local function handleWarning()
+ if App.shouldExit then
+ return
+ end
+ if session.status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK then
+ if not UI.warningDialog and not UI.warningDismissedAt then
+ if session.status.modelMismatch then
+ UI.warningDialog = ModelMismatchDialog.show(function()
+ UI.warningDismissedAt = getTime()
+ UI.invalidate()
+ end, function()
+ App.shouldExit = true
+ end)
+ elseif session.status.criticalError then
+ Dialogs.showMessage({
+ title = "Warning",
+ message = session.status.warning,
+ })
+ UI.warningDialog = true
+ UI.warningDismissedAt = getTime()
+ end
+ end
+ if UI.warningDismissedAt and getTime() - UI.warningDismissedAt > 6000 then
+ UI.warningDismissedAt = nil
+ UI.warningDialog = nil
+ end
+ else
+ UI.warningDialog = nil
+ if UI.warningDismissedAt and getTime() - UI.warningDismissedAt > 6000 then
+ UI.warningDismissedAt = nil
+ end
+ end
+end
+
+-- ============================================================================
+-- Interface: render
+-- ============================================================================
+
+function UI.render(_event, _touchState)
+ handleCommandPopup()
+
+ if not UI.commandDialog then
+ handleWarning()
+
+ if not UI.uiBuilt and session.fieldsCount > 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 = session:folderLoadProgress(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 = session:folderLoadProgress(nil)
+ if loaded and loaded < total and session.fieldsCount > 0 then
+ return string.format("Loading %d%%", math.floor(loaded / total * 100))
+ end
+
+ local status = session.status
+ local subtitle = ""
+ if status.receivedPackets then
+ local state = status.connected and "Telemetry OK" or "No telemetry"
+ subtitle = string.format("%u/%u • %s", status.lostPackets, status.receivedPackets, state)
+ end
+
+ if status.flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and status.warning and status.warning ~= "" then
+ if subtitle ~= "" then
+ subtitle = table.concat({ subtitle, " • ", status.warning })
+ else
+ subtitle = status.warning
+ end
+ end
+
+ return subtitle
+end
+
+-- ============================================================================
+-- Field value increment
+-- ============================================================================
+
+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
+ session:writeField(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
+ session:writeField(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 == crsf.CONST.FIELD_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
+ session:writeField(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
+ session:writeField(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()
+ session:execCommand(field)
+ end,
+ })
+end
+
+function UI.buildFieldWidget(pg, field)
+ if not field then
+ return
+ end
+
+ local fieldType = field.type
+
+ if fieldType == crsf.CONST.FIELD_COMMAND then
+ return UI.createCommandWidget(pg, field)
+ end
+
+ if fieldType <= crsf.CONST.FIELD_INT16 or fieldType == crsf.CONST.FIELD_FLOAT then
+ return UI.createNumberRow(pg, field)
+ end
+
+ if fieldType == crsf.CONST.FIELD_TEXT_SELECTION then
+ if UI.isBooleanField(field) then
+ return UI.createToggleRow(pg, field)
+ else
+ return UI.createChoiceRow(pg, field)
+ end
+ end
+
+ if fieldType == crsf.CONST.FIELD_STRING then
+ return UI.createStringRow(pg, field)
+ end
+
+ if fieldType == crsf.CONST.FIELD_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(session.devices) do
+ if device.id ~= session.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 = session:fieldsInFolder(currentFolder)
+
+ if currentFolder == nil then
+ UI.createInfoRow(fieldContainer, { name = "Device name", value = session.deviceName or "Searching..." })
+ end
+
+ 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 == crsf.CONST.FIELD_FOLDER then
+ local folderBatch = {}
+ while i <= #fieldsInFolder and fieldsInFolder[i].type == crsf.CONST.FIELD_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 session.isElrsTx then
+ UI.createInfoRow(fieldContainer, { name = "Lua script version", value = VERSION })
+ end
+
+ if currentFolder == nil and #session.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..905f7b9
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/loadable.lua
@@ -0,0 +1,697 @@
+---------------------------------------------------------------------------
+-- ELRS Telemetry Widget - Core Logic --
+-- Loaded via loadScript() from ELRSTelemetry/main.lua --
+-- --
+-- Displays ELRS link telemetry using LVGL. Uses the shared CRSF --
+-- singleton for transport and crsf_elrsinfo.lua for device info/match. --
+-- --
+-- UI is loaded from a screen-specific file in ui/ based on LCD_W/LCD_H.--
+---------------------------------------------------------------------------
+
+local zone, options, crsf, elrsinfo = ...
+
+-- Forward declarations for modules
+local Telemetry
+
+-- ============================================================================
+-- Telemetry Module: cell counting, state, power mapping
+-- ============================================================================
+
+Telemetry = {
+ -- Smoothed range percentage
+ smoothRng = nil,
+
+ -- Range percentage, recomputed once per tick by Telemetry.update()
+ rangePct = 0,
+
+ -- Cell count detection state
+ cellCnt = nil,
+ ---@type number|nil
+ cellCntCnt = nil,
+ cellLastV = nil,
+
+ -- Connection state, used to detect the falling edge on disconnect
+ wasConnected = false,
+
+ -- Cached GPS position (persists across disconnects)
+ ---@type {lat: number, lon: number}|nil
+ gps = nil,
+
+ -- Shared link snapshot, refilled once per tick by Telemetry.update().
+ -- The table identity never changes, so consumers may cache a reference.
+ link = {},
+
+ -- 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)
+ -- once the cellCnt is the same X times in a row, stop updating
+ if (Telemetry.cellCntCnt or 0) > 5 then
+ return
+ end
+
+ -- try to lock on to the cell count, so as the voltage sags we don't change S
+ local cellCnt = math.floor(v / 4.35) + 1
+ -- Prevent lock on when no voltage is present
+ if (v / cellCnt) < 3.0 then
+ return
+ end
+
+ if Telemetry.cellCnt ~= cellCnt then
+ Telemetry.cellCnt = cellCnt
+ Telemetry.cellCntCnt = 0
+ else
+ -- The value has to change to count as an update
+ if Telemetry.cellLastV == v then
+ return
+ end
+ Telemetry.cellLastV = v
+ Telemetry.cellCntCnt = Telemetry.cellCntCnt + 1
+ end
+end
+
+--- Check if a CRSF/ELRS module is available.
+function Telemetry.hasModule()
+ return crsf.hasCrsfModule()
+end
+
+--- True when a connected link is reporting a model mismatch.
+--- modelMismatch arrives in the same ELRS_STATUS frame as hasTelemetry, so it only means
+--- anything while connected: ungated, a stale flag paints a label RED under text reading "--".
+function Telemetry.isMismatch()
+ return crsf.hasTelemetry and elrsinfo.modelMismatch
+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 elrsinfo.modelMismatch then
+ return "Model Mismatch"
+ end
+ return nil
+end
+
+--- Compute smoothed range percentage from RSSI.
+function Telemetry.getRangePct(tlm)
+ local mod = elrsinfo.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 = elrsinfo.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
+
+--- Whether the RX reports a second antenna.
+--- An RX only writes uplink_RSSI_2 when it has two RF paths — a dual-radio RX
+--- fills it every packet, a switched-antenna RX once it first selects antenna 2.
+--- A single-antenna RX never touches it, so it arrives as 0 dBm, impossible for
+--- a real signal. Same test ExpressLRS uses on the TX module's own screens.
+function Telemetry.hasDiversity()
+ local rssi2 = Telemetry.link.rssi2
+ return rssi2 ~= nil and rssi2 ~= 0
+end
+
+--- Pick the active antenna's RSSI value from a link snapshot.
+function Telemetry.getRssi(tlm)
+ if not tlm then
+ return nil
+ end
+ return (tlm.ant == 1) and tlm.rssi2 or tlm.rssi1
+end
+
+--- Clear per-connection state.
+--- The original discarded its whole ctx table on RX disconnect, so cell count and
+--- range smoothing re-detect on the next battery instead of latching forever.
+--- gps is deliberately kept — the port caches last-known position across dropouts.
+function Telemetry.resetConnection()
+ Telemetry.smoothRng = nil
+ Telemetry.rangePct = 0
+ Telemetry.cellCnt = nil
+ Telemetry.cellCntCnt = nil
+ Telemetry.cellLastV = nil
+end
+
+--- Refill the shared link snapshot and recompute all derived state.
+--- Called once per tick from wgt.background(). EdgeTX runs wgt.refresh() immediately
+--- before it evaluates every LVGL text/color callback, so labels always read values
+--- sampled in their own frame and every field sees the same snapshot.
+function Telemetry.update()
+ local link = Telemetry.link
+ link.tpwr = crsf.getSensorValue("TPWR")
+ link.rfmd = crsf.getSensorValue("RFMD")
+ link.rssi1 = crsf.getSensorValue("1RSS")
+ link.rssi2 = crsf.getSensorValue("2RSS")
+ link.rqly = crsf.getSensorValue("RQly")
+ link.ant = crsf.getSensorValue("ANT")
+ link.vbat = crsf.getSensorValue("RxBt")
+
+ local connected = crsf.hasTelemetry
+ if Telemetry.wasConnected and not connected then
+ Telemetry.resetConnection()
+ end
+ Telemetry.wasConnected = connected
+ if not connected then
+ return
+ end
+
+ Telemetry.updateGps()
+ Telemetry.rangePct = Telemetry.getRangePct(link)
+ if link.vbat then
+ Telemetry.checkCellCount(link.vbat)
+ end
+end
+
+--- Hero label colour: red only while a connected link reports a model mismatch.
+function Telemetry.heroColor()
+ if Telemetry.isMismatch() then
+ return RED
+ end
+ return COLOR_THEME_PRIMARY1
+end
+
+--- Hero label font for one tier of a screen's WidgetUI.fonts table.
+--- Status text ("No CRSF module") is far longer than "LQ 100%", so tiers that would
+--- overflow declare a smaller heroStatus and drop to it while a status shows.
+--- Tiers without one get the constant back, so no callback runs per frame.
+function Telemetry.heroFont(tier)
+ if not tier.heroStatus then
+ return tier.hero
+ end
+ return function()
+ if Telemetry.statusText() then
+ return tier.heroStatus
+ end
+ return tier.hero
+ end
+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 parts = { table.concat({ "Range ", tostring(Telemetry.rangePct), "%" }) }
+ local rssi = Telemetry.getRssi(Telemetry.link)
+ if rssi then
+ parts[#parts + 1] = table.concat({ tostring(rssi), "dBm" })
+ end
+ return table.concat(parts, " ")
+end
+
+--- RF mode text (e.g. "250Hz"). Narrow zones use this without the power suffix.
+function Telemetry.rfModeText()
+ return Telemetry.getRfModeStr(Telemetry.link.rfmd)
+end
+
+--- RF mode + TX power text (e.g. "250Hz 50mW").
+function Telemetry.rfDetailText()
+ local parts = { Telemetry.rfModeText() }
+ local tpwr = Telemetry.link.tpwr
+ if crsf.hasTelemetry and tpwr then
+ parts[#parts + 1] = table.concat({ tostring(tpwr), "mW" })
+ end
+ return table.concat(parts, " ")
+end
+
+--- Battery text for minimized layouts (e.g. "Bat 4S 3.80V").
+function Telemetry.batteryText()
+ local vbat = Telemetry.link.vbat
+ if vbat == nil or vbat <= 0 then
+ return ""
+ end
+ local cells = Telemetry.cellCnt
+ if cells then
+ return string.format("Bat %dS %.2fV", cells, vbat / cells)
+ end
+ return string.format("Bat %.2fV", vbat)
+end
+
+--- Battery text for the full-screen row (e.g. "4S 3.80V (15.20V)").
+function Telemetry.batteryTextVerbose()
+ local vbat = Telemetry.link.vbat
+ if vbat == nil or vbat <= 0 then
+ return "--"
+ end
+ local cells = Telemetry.cellCnt
+ if cells then
+ return string.format("%dS %.2fV (%.2fV)", cells, vbat / cells, vbat)
+ end
+ return string.format("%.2fV", vbat)
+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 (T15, T15 Pro, TX15, ST16, PL18)
+ else
+ return "sd" -- 480x272 (TX16S, MAX, Mk II)
+ 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 elrsinfo.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 = Telemetry.isMismatch,
+ },
+ })
+
+ -- Link Status section
+ createSectionHeader(fields, "Link Status")
+
+ createDisplayRow(fields, "RF Mode", Telemetry.rfModeText)
+
+ createDisplayRow(fields, "Link Quality", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ return table.concat({ tostring(Telemetry.link.rqly or 0), "%" })
+ end)
+
+ createDisplayRow(fields, "RSSI 1", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ local rssi1 = Telemetry.link.rssi1
+ if rssi1 == nil then
+ return "--"
+ end
+ return table.concat({ tostring(rssi1), " dBm" })
+ end)
+
+ createDisplayRow(fields, "RSSI 2", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ local rssi2 = Telemetry.link.rssi2
+ if rssi2 == nil then
+ return "--"
+ end
+ return table.concat({ tostring(rssi2), " dBm" })
+ end, function()
+ if not Telemetry.hasDiversity() then
+ return COLOR_THEME_DISABLED
+ end
+ return COLOR_THEME_SECONDARY1
+ end)
+
+ createDisplayRow(fields, "Active Antenna", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ if not Telemetry.hasDiversity() then
+ return "N/A"
+ end
+ -- EdgeTX's telemetry list prints the raw ANT enum (0/1) and so does the TX
+ -- module's own screen. The "Ant " prefix keeps this row from reading as that
+ -- same number, and 1/2 matches the RSSI 1 / RSSI 2 rows above.
+ if Telemetry.link.ant == 0 then
+ return "Ant 1"
+ end
+ if Telemetry.link.ant == 1 then
+ return "Ant 2"
+ end
+ return "--"
+ end)
+
+ createDisplayRow(fields, "Range", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ return table.concat({ tostring(Telemetry.rangePct), "%" })
+ end)
+
+ -- Power section
+ createSectionHeader(fields, "Power")
+
+ createDisplayRow(fields, "TX Power", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ local tpwr = Telemetry.link.tpwr
+ if tpwr == nil then
+ return "--"
+ end
+ return table.concat({ tostring(tpwr), " mW" })
+ end)
+
+ createDisplayRow(fields, "Power Index", function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ local tpwr = Telemetry.link.tpwr
+ if tpwr == nil then
+ return "--"
+ end
+ return tostring(Telemetry.pwrToIdx(tpwr))
+ end)
+
+ -- Flight Controller section
+ createSectionHeader(fields, "Flight Controller")
+
+ createDisplayRow(fields, "Battery", Telemetry.batteryTextVerbose)
+
+ 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()
+ elrsinfo:update()
+ Telemetry.update()
+end
+
+function wgt.refresh(_event, _touchState)
+ wgt.background()
+end
+
+function wgt.update(newOptions)
+ wgt.options = newOptions
+ if lvgl.isFullScreen() then
+ buildFullScreen()
+ else
+ WidgetUI.build(wgt.zone, wgt.options)
+ end
+end
+
+-- Populate the snapshot before the first paint: update() runs callRefs without a
+-- preceding refresh(), so label callbacks can fire before the first background tick.
+Telemetry.update()
+
+-- 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..ee119c2
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/main.lua
@@ -0,0 +1,51 @@
+---------------------------------------------------------------------------
+-- 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/ELRS 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
+ if not _elrsInfoSingleton then
+ local getElrsInfo = loadScript("/SCRIPTS/ELRS/crsf_elrsinfo.lua")
+ ---@diagnostic disable-next-line: need-check-nil
+ _elrsInfoSingleton = getElrsInfo(_crsfSingleton)
+ end
+ local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua")
+ ---@diagnostic disable-next-line: need-check-nil
+ return loadable(zone, options, _crsfSingleton, _elrsInfoSingleton)
+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..9fdc1de
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua
@@ -0,0 +1,276 @@
+---------------------------------------------------------------------------
+-- 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,
+ halfH = 154,
+}
+
+WidgetUI.fonts = {
+ sixth = { hero = BOLD },
+ quarter = { hero = BOLD },
+ third = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE },
+ full = { hero = MIDSIZE, heroStatus = 0, detail = 0 },
+}
+
+-- ============================================================================
+-- Minimized display helpers
+-- ============================================================================
+
+local function detailColor()
+ if not crsf.hasTelemetry then
+ return COLOR_THEME_SECONDARY1
+ end
+ return Telemetry.rangeColor(Telemetry.rangePct)
+end
+
+local function heroTextLq()
+ local status = Telemetry.statusText()
+ if status then
+ return status
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.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 = WidgetUI.fonts.sixth.hero,
+ color = Telemetry.heroColor,
+ 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 = WidgetUI.fonts.quarter.hero,
+ color = Telemetry.heroColor,
+ 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 = Telemetry.heroFont(WidgetUI.fonts.third),
+ color = Telemetry.heroColor,
+ 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
+
+--- Data rows shared by the 1/2 and 1/1 tiers:
+--- LQ, Range/RSSI, RF mode/power, battery.
+local function appendDataRows(rows)
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = Telemetry.heroFont(WidgetUI.fonts.full),
+ color = Telemetry.heroColor,
+ text = heroTextLq,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.detail,
+ color = detailColor,
+ text = Telemetry.signalText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.rfDetailText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.batteryText,
+ }
+end
+
+--- 1/2: the four data rows without the title.
+--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom.
+function WidgetUI.buildHalf(w, h, opa)
+ local rows = {}
+ appendDataRows(rows)
+ 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",
+ },
+ }
+ appendDataRows(rows)
+ 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/ELRSTelemetry/ui/portrait.lua b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua
new file mode 100644
index 0000000..423f7aa
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua
@@ -0,0 +1,240 @@
+---------------------------------------------------------------------------
+-- 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, heroStatus = BOLD, detail = SMLSIZE },
+}
+
+-- ============================================================================
+-- Minimized display helpers
+-- ============================================================================
+
+local function detailColor()
+ if not crsf.hasTelemetry then
+ return COLOR_THEME_SECONDARY1
+ end
+ return Telemetry.rangeColor(Telemetry.rangePct)
+end
+
+local function heroTextLq()
+ local status = Telemetry.statusText()
+ if status then
+ return status
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.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 = WidgetUI.fonts.sixth.hero,
+ color = Telemetry.heroColor,
+ 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 = WidgetUI.fonts.quarter.hero,
+ color = Telemetry.heroColor,
+ 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 = Telemetry.heroColor,
+ 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
+
+--- Data rows shared by the 1/2 and 1/1 tiers:
+--- LQ, Range/RSSI, RF mode/power, battery.
+local function appendDataRows(rows)
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = Telemetry.heroFont(WidgetUI.fonts.full),
+ color = Telemetry.heroColor,
+ text = heroTextLq,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.detail,
+ color = detailColor,
+ text = Telemetry.signalText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.rfDetailText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.batteryText,
+ }
+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",
+ },
+ }
+ appendDataRows(rows)
+ 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..bb56a20
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua
@@ -0,0 +1,268 @@
+---------------------------------------------------------------------------
+-- ELRS Telemetry Widget - UI for 480x272 (SD) --
+-- Standard definition landscape (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 480x272.
+WidgetUI.breakpoints = {
+ topBarW = 100,
+ sixthH = 37,
+ quarterH = 52,
+ thirdH = 73,
+ halfH = 108,
+}
+
+WidgetUI.fonts = {
+ sixth = { hero = BOLD },
+ quarter = { hero = BOLD },
+ third = { hero = BOLD, detail = SMLSIZE },
+ full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE },
+}
+
+-- ============================================================================
+-- Minimized display helpers
+-- ============================================================================
+
+local function detailColor()
+ if not crsf.hasTelemetry then
+ return COLOR_THEME_SECONDARY1
+ end
+ return Telemetry.rangeColor(Telemetry.rangePct)
+end
+
+local function heroTextLq()
+ local status = Telemetry.statusText()
+ if status then
+ return status
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.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 = WidgetUI.fonts.sixth.hero,
+ color = Telemetry.heroColor,
+ 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 = WidgetUI.fonts.quarter.hero,
+ color = Telemetry.heroColor,
+ 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 = Telemetry.heroColor,
+ 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
+
+--- Data rows shared by the 1/2 and 1/1 tiers:
+--- LQ, Range/RSSI, RF mode/power, battery.
+local function appendDataRows(rows)
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = Telemetry.heroFont(WidgetUI.fonts.full),
+ color = Telemetry.heroColor,
+ text = heroTextLq,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.detail,
+ color = detailColor,
+ text = Telemetry.signalText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.rfDetailText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.batteryText,
+ }
+end
+
+--- 1/2: the four data rows without the title.
+--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom.
+function WidgetUI.buildHalf(w, h, opa)
+ local rows = {}
+ appendDataRows(rows)
+ 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",
+ },
+ }
+ appendDataRows(rows)
+ 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/ELRSTelemetry/ui/sd_tall.lua b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua
new file mode 100644
index 0000000..62d4aeb
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua
@@ -0,0 +1,275 @@
+---------------------------------------------------------------------------
+-- ELRS Telemetry Widget - UI for 480x320 (SD Tall) --
+-- Jumper T15, T15 Pro, TX15, ST16, PL18 --
+---------------------------------------------------------------------------
+
+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,
+ halfH = 108,
+}
+
+WidgetUI.fonts = {
+ sixth = { hero = BOLD },
+ quarter = { hero = BOLD },
+ third = { hero = BOLD, detail = SMLSIZE },
+ full = { hero = MIDSIZE, heroStatus = BOLD, detail = SMLSIZE },
+}
+
+-- ============================================================================
+-- Minimized display helpers
+-- ============================================================================
+
+local function detailColor()
+ if not crsf.hasTelemetry then
+ return COLOR_THEME_SECONDARY1
+ end
+ return Telemetry.rangeColor(Telemetry.rangePct)
+end
+
+local function heroTextLq()
+ local status = Telemetry.statusText()
+ if status then
+ return status
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.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 = WidgetUI.fonts.sixth.hero,
+ color = Telemetry.heroColor,
+ 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 = WidgetUI.fonts.quarter.hero,
+ color = Telemetry.heroColor,
+ 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 = WidgetUI.fonts.third.hero,
+ color = Telemetry.heroColor,
+ 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
+
+--- Data rows shared by the 1/2 and 1/1 tiers:
+--- LQ, Range/RSSI, RF mode/power, battery.
+local function appendDataRows(rows)
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = Telemetry.heroFont(WidgetUI.fonts.full),
+ color = Telemetry.heroColor,
+ text = heroTextLq,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.detail,
+ color = detailColor,
+ text = Telemetry.signalText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.rfDetailText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.batteryText,
+ }
+end
+
+--- 1/2: the four data rows without the title.
+--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom.
+function WidgetUI.buildHalf(w, h, opa)
+ local rows = {}
+ appendDataRows(rows)
+ 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",
+ },
+ }
+ appendDataRows(rows)
+ 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/ELRSTelemetry/ui/small.lua b/src/WIDGETS/ELRSTelemetry/ui/small.lua
new file mode 100644
index 0000000..e9ddca6
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua
@@ -0,0 +1,267 @@
+---------------------------------------------------------------------------
+-- 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,
+ halfH = 82,
+}
+
+WidgetUI.fonts = {
+ sixth = { hero = BOLD },
+ quarter = { hero = BOLD },
+ third = { hero = BOLD, detail = SMLSIZE },
+ full = { hero = BOLD, detail = SMLSIZE },
+}
+
+-- ============================================================================
+-- Minimized display helpers
+-- ============================================================================
+
+local function detailColor()
+ if not crsf.hasTelemetry then
+ return COLOR_THEME_SECONDARY1
+ end
+ return Telemetry.rangeColor(Telemetry.rangePct)
+end
+
+local function heroTextLq()
+ local status = Telemetry.statusText()
+ if status then
+ return status
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.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 = WidgetUI.fonts.sixth.hero,
+ color = Telemetry.heroColor,
+ 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.rfModeText,
+ },
+ },
+ },
+ }
+ 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 = WidgetUI.fonts.quarter.hero,
+ color = Telemetry.heroColor,
+ 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 = Telemetry.heroColor,
+ 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
+
+--- Data rows shared by the 1/2 and 1/1 tiers:
+--- LQ, Range/RSSI, RF mode/power, battery.
+local function appendDataRows(rows)
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.hero,
+ color = Telemetry.heroColor,
+ text = heroTextLq,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = WidgetUI.fonts.full.detail,
+ color = detailColor,
+ text = Telemetry.signalText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.rfDetailText,
+ }
+ rows[#rows + 1] = {
+ type = lvgl.LABEL,
+ align = LEFT,
+ font = SMLSIZE,
+ color = COLOR_THEME_SECONDARY1,
+ text = Telemetry.batteryText,
+ }
+end
+
+--- 1/2: the four data rows without the title.
+--- Sits between 1/3 and 1/1 so the battery row is never clipped off the bottom.
+function WidgetUI.buildHalf(w, h, opa)
+ local rows = {}
+ appendDataRows(rows)
+ 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",
+ },
+ }
+ appendDataRows(rows)
+ 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/ELRSTelemetry/ui/topbar.lua b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua
new file mode 100644
index 0000000..97b9e9b
--- /dev/null
+++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua
@@ -0,0 +1,72 @@
+---------------------------------------------------------------------------
+-- 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 = {}
+
+--- Top bar sits on the dark header, so it needs PRIMARY2 rather than Telemetry.heroColor's PRIMARY1.
+local function mismatchColor()
+ if Telemetry.isMismatch() then
+ return RED
+ end
+ return COLOR_THEME_PRIMARY2
+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 = mismatchColor,
+ text = function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ if Telemetry.isMismatch() then
+ return "Model"
+ end
+ return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" })
+ end,
+ },
+ {
+ type = lvgl.LABEL,
+ align = CENTER,
+ font = SMLSIZE,
+ color = mismatchColor,
+ text = function()
+ if not crsf.hasTelemetry then
+ return "--"
+ end
+ if Telemetry.isMismatch() then
+ return "Mismatch"
+ end
+ local rssi = Telemetry.getRssi(Telemetry.link)
+ 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..0339fad
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua
@@ -0,0 +1,592 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - Core --
+-- Loaded via loadScript() from ELRSVTXAdmin/main.lua --
+-- --
+-- Home of the VTXAdmin component: client of the ELRS TX module's VTX --
+-- Administrator over the CRSF config protocol (PARAMETER_READ/WRITE). --
+-- Field IDs are discovered at runtime by name -- never hardcoded. --
+-- --
+-- Wires the components around it -- presets_storage.lua, ui/display.lua, --
+-- ui/fullscreen.lua, the shared ELRS file_storage library -- and a --
+-- screen-specific minimized layout from ui/ picked by LCD_W/LCD_H, --
+-- then runs the widget lifecycle. Loaded fresh per widget instance, so --
+-- every table here is per-instance state. --
+---------------------------------------------------------------------------
+
+local zone, options, crsf, CRSFSession = ...
+
+-- ============================================================================
+-- Components: file storage (shared ELRS library) and 6POS preset settings
+-- ============================================================================
+
+local FileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua")()
+local PresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua")(FileStorage)
+
+-- ============================================================================
+-- VTXAdmin: client of the ELRS "VTX Administrator" service on the TX module
+-- Discovery state machine, current/desired VTX state, write policy,
+-- 6POS quick-change and push-trigger automation
+-- ============================================================================
+
+-- Every widget instance owns a CRSF parameter session in passive fan-out
+-- mode: any field from the TX module is accepted, so sibling instances see
+-- every response they did not request themselves.
+local session
+
+local VTXAdmin = {
+ -- State machine phase constants. "phase" rather than "state": VTXAdmin.state
+ -- below is the VTX state parsed from the folder name.
+ PHASE_INIT = 0,
+ PHASE_NO_MODULE = 1,
+ PHASE_DISCOVER_ROOT = 2,
+ PHASE_DISCOVER_CHILDREN = 3,
+ PHASE_DISCOVER_VTX = 4,
+ PHASE_READY = 5,
+ PHASE_SENDING = 6,
+
+ -- Current state machine phase
+ phase = 0, -- PHASE_INIT
+
+ -- Previous tick timestamp, to detect suspension: a standalone tool pauses
+ -- widget scripts, and whatever it changed needs one read-back on resume.
+ lastTick = 0,
+
+ -- 6POS processing state: last consumed position (-1 until the first settle;
+ -- read by the cheatsheet highlight and the full-screen rebuild tracking)
+ -- plus the debounce bookkeeping.
+ lastPos = -1,
+ stablePos = -1,
+ stableTime = 0,
+ DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each)
+
+ -- Push trigger edge detection state. nil until the first sample has been
+ -- taken; the full-screen editor resets it on trigger-source reassignment.
+ ---@type boolean?
+ pushLastHigh = nil,
+
+ -- Field IDs (discovered at runtime)
+ ids = {
+ folder = nil,
+ band = nil,
+ channel = nil,
+ power = nil,
+ pitmode = nil,
+ send = nil,
+ },
+
+ -- Band lookup tables. BAND_LETTERS is 1-based: band 0 has no letter, and its label
+ -- differs by context ("Off" for a disabled VTX, "--" for an unused 6POS preset slot).
+ BAND_LETTERS = { "A", "B", "E", "F", "R", "L" },
+ BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 },
+
+ -- Status line shown by the UI until discovery finishes
+ statusText = "Initializing...",
+
+ -- Current VTX state (parsed from folder name). Stable table identity:
+ -- mutated in place, never replaced -- UI closures capture a reference.
+ state = {
+ band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L
+ bandLetter = "?",
+ channel = 0,
+ power = 0,
+ pitmode = false, -- true only when pit mode is confirmed on
+ pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch
+ },
+
+ -- Desired VTX state (edited by user in full-screen UI). Same stable
+ -- identity contract as state.
+ desired = {
+ band = 5, -- Raceband
+ channel = 1,
+ power = 0,
+ pitmode = 0,
+ },
+}
+
+--- Parse "VTX Admin (R:4:2:P)" into VTXAdmin.state fields.
+-- ExpressLRS writes "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])": band Off drops the whole
+-- suffix, power "-" drops both power and pit mode, pit mode Off drops itself. PITMODE is "P"
+-- when set to On, or the aux label ("AUX1\192".."AUX10\193", \192/\193 = up/down arrow) when
+-- bound to a switch. The name carries only the binding, never the switch position, so an aux
+-- binding sets pitmodeAux and leaves pitmode false.
+local function parseFolderName(name)
+ local s = VTXAdmin.state
+ local content = string.match(name, "%((.+)%)")
+ if not content then
+ s.band = 0
+ s.bandLetter = "Off"
+ s.channel = 0
+ s.power = 0
+ s.pitmode = false
+ s.pitmodeAux = nil
+ return true
+ end
+
+ local parts = {}
+ for part in string.gmatch(content, "([^:]+)") do
+ parts[#parts + 1] = part
+ end
+ if #parts < 2 then
+ return false
+ end
+
+ s.bandLetter = parts[1]
+ s.band = VTXAdmin.BAND_VALUES[parts[1]] or 0
+ s.channel = tonumber(parts[2]) or 0
+ s.power = tonumber(parts[3]) or 0
+ if #parts < 4 then
+ s.pitmode = false
+ s.pitmodeAux = nil
+ elseif parts[4] == "P" then
+ s.pitmode = true
+ s.pitmodeAux = nil
+ else
+ -- Strip the trailing up/down arrow: the shared decoder translates the
+ -- firmware's one-byte arrows into the (multi-byte) CHAR_UP/CHAR_DOWN
+ -- glyphs before the name reaches us.
+ s.pitmode = false
+ local part = parts[4]
+ if CHAR_UP and string.sub(part, -#CHAR_UP) == CHAR_UP then
+ s.pitmodeAux = string.sub(part, 1, -#CHAR_UP - 1)
+ elseif CHAR_DOWN and string.sub(part, -#CHAR_DOWN) == CHAR_DOWN then
+ s.pitmodeAux = string.sub(part, 1, -#CHAR_DOWN - 1)
+ else
+ s.pitmodeAux = string.sub(part, 1, -2)
+ end
+ end
+ return true
+end
+
+--- True when the VTX is tuned to a band.
+function VTXAdmin.isTuned()
+ return VTXAdmin.isActive() and VTXAdmin.state.band > 0
+end
+
+--- True when the module is up but the VTX band is set to Off.
+function VTXAdmin.isDisabled()
+ return VTXAdmin.isActive() and VTXAdmin.state.band == 0
+end
+
+--- True when a power level is set. ExpressLRS omits power and pit mode from the VTX Admin
+--- folder name when power is "-", and hides the Pitmode field entirely, so neither value is
+--- meaningful until a power level is chosen.
+function VTXAdmin.hasPower()
+ return VTXAdmin.isTuned() and VTXAdmin.state.power > 0
+end
+
+--- Sync desired values with current state (e.g. on discovery or entering full-screen).
+function VTXAdmin.syncDesiredFromState()
+ local s = VTXAdmin.state
+ local d = VTXAdmin.desired
+ d.band = s.band
+ d.channel = s.channel
+ d.power = s.power
+ d.pitmode = s.pitmode and 1 or 0
+end
+
+-- ============================================================================
+-- VTXAdmin: state machine query helpers
+-- ============================================================================
+
+function VTXAdmin.isReady()
+ return VTXAdmin.phase == VTXAdmin.PHASE_READY
+end
+
+function VTXAdmin.isSending()
+ return VTXAdmin.phase == VTXAdmin.PHASE_SENDING
+end
+
+function VTXAdmin.isActive()
+ return VTXAdmin.phase == VTXAdmin.PHASE_READY or VTXAdmin.phase == VTXAdmin.PHASE_SENDING
+end
+
+--- True when a CRSF module answered discovery. Weaker than isActive(): the 6POS preset
+--- cheatsheet is local radio state read from presets.txt, not VTX telemetry, so it is worth
+--- showing before discovery finishes.
+function VTXAdmin.hasModule()
+ return VTXAdmin.phase ~= VTXAdmin.PHASE_NO_MODULE
+end
+
+-- ============================================================================
+-- VTXAdmin: field handler (session onFieldUpdate callback)
+-- ============================================================================
+
+local function onField(field)
+ local fieldId = field.id
+ local fieldName = field.name
+
+ if VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_ROOT then
+ if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then
+ -- The session auto-queues the root children off this entry
+ VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_CHILDREN
+ VTXAdmin.statusText = "Discovering fields..."
+ end
+ elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_CHILDREN then
+ if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then
+ VTXAdmin.ids.folder = fieldId
+ parseFolderName(fieldName)
+ session:loadFolder(fieldId)
+ VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_VTX
+ VTXAdmin.statusText = "Loading VTX fields..."
+ elseif not session:isLoading() and VTXAdmin.ids.folder == nil then
+ VTXAdmin.statusText = "VTX Admin not found"
+ end
+ elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_VTX then
+ if fieldName == "Band" or fieldName == "Band/Enable" then
+ VTXAdmin.ids.band = fieldId
+ elseif fieldName == "Channel" then
+ VTXAdmin.ids.channel = fieldId
+ elseif fieldName == "Pwr Lvl" then
+ VTXAdmin.ids.power = fieldId
+ elseif fieldName == "Pitmode" then
+ VTXAdmin.ids.pitmode = fieldId
+ elseif fieldName == "Send VTx" then
+ VTXAdmin.ids.send = fieldId
+ end
+
+ if not session:isLoading() then
+ if
+ VTXAdmin.ids.band
+ and VTXAdmin.ids.channel
+ and VTXAdmin.ids.power
+ and VTXAdmin.ids.pitmode
+ and VTXAdmin.ids.send
+ then
+ VTXAdmin.phase = VTXAdmin.PHASE_READY
+ VTXAdmin.statusText = ""
+ VTXAdmin.syncDesiredFromState()
+ else
+ VTXAdmin.statusText = "VTX fields incomplete"
+ end
+ end
+ elseif VTXAdmin.phase == VTXAdmin.PHASE_READY then
+ if fieldId == VTXAdmin.ids.folder then
+ parseFolderName(fieldName)
+ end
+ end
+end
+
+session = CRSFSession.new({
+ acceptUnsolicited = true,
+ responseTimeout = 50, -- always the local TX module
+ onFieldUpdate = onField,
+})
+-- Register on the shared CRSF singleton's fan-out
+session:attachBus()
+
+-- ============================================================================
+-- VTXAdmin: 6POS quick-change and push-trigger automation
+-- ============================================================================
+
+local function mapTo6Pos(value)
+ local pos = math.floor((value + 1024) * 6 / 2049) + 1
+ if pos < 1 then
+ pos = 1
+ end
+ if pos > 6 then
+ pos = 6
+ end
+ return pos
+end
+
+--- Runs every tick. Reads the 6POS source, debounces, and applies the
+--- matching preset on edge-detected position changes.
+local function process6Pos()
+ if not PresetsStorage.enabled then
+ return
+ end
+ if PresetsStorage.source == 0 then
+ return
+ end
+
+ local value = getValue(PresetsStorage.source)
+ if value == nil then
+ return
+ end
+
+ local pos = mapTo6Pos(value)
+ local now = getTime()
+
+ -- Debounce: require stable position for DEBOUNCE ticks
+ if pos ~= VTXAdmin.stablePos then
+ VTXAdmin.stablePos = pos
+ VTXAdmin.stableTime = now
+ return
+ end
+ if now - VTXAdmin.stableTime < VTXAdmin.DEBOUNCE then
+ return
+ end
+
+ -- Only consume a position once a write can actually land. writeConfig() drops
+ -- everything outside the ready phase, and lastPos is latched before it is called, so
+ -- latching any earlier discards the position permanently -- during a send, and during
+ -- discovery whenever it outruns the debounce. Holding until ready is also what makes
+ -- the first tick after discovery assert the boot position to the module.
+ if not VTXAdmin.isReady() then
+ return
+ end
+
+ -- Edge-triggered: only send on position change
+ if pos == VTXAdmin.lastPos then
+ return
+ end
+ VTXAdmin.lastPos = pos
+
+ local preset = PresetsStorage.items[pos]
+ if preset and preset.band > 0 then
+ print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> band=", preset.band, " ch=", preset.channel }))
+ VTXAdmin.applyPreset(preset.band, preset.channel)
+ if PresetsStorage.autoPushVtx then
+ VTXAdmin.pushToVtx()
+ end
+ else
+ print(table.concat({ "VTXAdmin: 6POS pos=", pos, " -> Off (skipped)" }))
+ end
+end
+
+--- Runs every tick. Edge-detects the pushSource going high and triggers
+--- pushToVtx() to send the current config to the VTX.
+local function processPushTrigger()
+ if PresetsStorage.autoPushVtx then
+ return
+ end
+ if PresetsStorage.pushSource == 0 then
+ return
+ end
+
+ local val = getValue(PresetsStorage.pushSource)
+ if val == nil then
+ return
+ end
+
+ local high = val > 0
+ local wasHigh = VTXAdmin.pushLastHigh
+ VTXAdmin.pushLastHigh = high
+
+ if wasHigh == nil then
+ -- First sample after create or reassignment: adopt the level without firing. A
+ -- source that is already high was not just moved there by the user.
+ return
+ end
+
+ -- Edge detection: trigger only on rising edge (low -> high)
+ if high and not wasHigh then
+ print("VTXAdmin: push source triggered - sending VTx command")
+ VTXAdmin.pushToVtx()
+ end
+end
+
+-- ============================================================================
+-- VTXAdmin: state machine tick
+-- ============================================================================
+
+function VTXAdmin.tick()
+ local now = getTime()
+
+ -- A tick gap over a second means the widget was suspended — a standalone
+ -- tool had the screen and may have changed the module config — so read the
+ -- folder back once on resume. The bounded refresh slot retries a lost
+ -- frame without ever polling: every push replaces one RC-channels frame
+ -- on the handset->module UART, so the folder is only read when something
+ -- can have changed it.
+ if VTXAdmin.lastTick > 0 and now - VTXAdmin.lastTick > 100 and VTXAdmin.phase == VTXAdmin.PHASE_READY then
+ session:refreshField(VTXAdmin.ids.folder, 0, 3)
+ end
+ VTXAdmin.lastTick = now
+
+ if VTXAdmin.phase == VTXAdmin.PHASE_INIT then
+ if crsf.hasCrsfModule() then
+ VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_ROOT
+ VTXAdmin.statusText = "Discovering..."
+ session:reloadAll()
+ else
+ VTXAdmin.phase = VTXAdmin.PHASE_NO_MODULE
+ VTXAdmin.statusText = "No CRSF module"
+ end
+ elseif VTXAdmin.phase == VTXAdmin.PHASE_SENDING and not session:isWriting() then
+ print("VTXAdmin: write queue drained")
+ VTXAdmin.phase = VTXAdmin.PHASE_READY
+ -- Read the folder back ~100ms after the last write so the module has
+ -- applied the change; the simulator defers folder-name updates ~20ms.
+ session:refreshField(VTXAdmin.ids.folder, 10, 3)
+ end
+
+ session:tick()
+
+ -- After the session pump, matching the write-queue timing the automation
+ -- had as separate background() calls: writes it queues go out next tick.
+ process6Pos()
+ processPushTrigger()
+end
+
+-- ============================================================================
+-- VTXAdmin: 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 VTXAdmin.writeConfig()
+ if not VTXAdmin.isReady() then
+ print("VTXAdmin: writeConfig() skipped - not ready")
+ return
+ end
+
+ local s = VTXAdmin.state
+ local d = VTXAdmin.desired
+
+ print(table.concat({
+ "VTXAdmin: writeConfig() desired: band=",
+ d.band,
+ " ch=",
+ d.channel,
+ " pwr=",
+ d.power,
+ " pit=",
+ tostring(d.pitmode),
+ }))
+ print(table.concat({
+ "VTXAdmin: writeConfig() current: band=",
+ s.band,
+ " ch=",
+ s.channel,
+ " pwr=",
+ s.power,
+ " pit=",
+ tostring(s.pitmode),
+ }))
+
+ local wrote = 0
+ if d.band ~= s.band then
+ session:writeField({ id = VTXAdmin.ids.band, value = d.band })
+ wrote = wrote + 1
+ end
+ if d.channel ~= s.channel then
+ session:writeField({ id = VTXAdmin.ids.channel, value = d.channel })
+ wrote = wrote + 1
+ end
+ if d.power ~= s.power then
+ session:writeField({ id = VTXAdmin.ids.power, value = d.power })
+ wrote = wrote + 1
+ end
+
+ local desiredPit = d.pitmode
+ local currentPit = s.pitmode and 1 or 0
+ if desiredPit ~= currentPit then
+ session:writeField({ id = VTXAdmin.ids.pitmode, value = desiredPit })
+ wrote = wrote + 1
+ end
+
+ print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" }))
+
+ if wrote > 0 then
+ VTXAdmin.phase = VTXAdmin.PHASE_SENDING
+ end
+end
+
+--- Write a preset's band and channel on top of the module's current state.
+--- Re-basing on state means a preset only ever writes band and channel:
+--- desired can hold stale power/pitmode -- ExpressLRS omits both from the
+--- folder name when power is "-", and nothing re-syncs after a send completes.
+function VTXAdmin.applyPreset(band, channel)
+ VTXAdmin.syncDesiredFromState()
+ VTXAdmin.desired.band = band
+ VTXAdmin.desired.channel = channel
+ VTXAdmin.writeConfig()
+end
+
+--- Send the "Send VTx" command, pushing config to the VTX.
+function VTXAdmin.pushToVtx()
+ if not VTXAdmin.isReady() and VTXAdmin.phase ~= VTXAdmin.PHASE_SENDING then
+ print("VTXAdmin: pushToVtx() skipped - not ready")
+ return
+ end
+
+ print("VTXAdmin: pushToVtx() - sending Send VTx command")
+ session:writeField({ id = VTXAdmin.ids.send, value = crsf.CONST.CMD_CLICK })
+ VTXAdmin.phase = VTXAdmin.PHASE_SENDING
+end
+
+-- ============================================================================
+-- Display components
+-- ============================================================================
+
+local VTXDisplay, WidgetLayout = loadScript("/WIDGETS/ELRSVTXAdmin/ui/display.lua")(VTXAdmin, PresetsStorage)
+local FullScreenUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua")(VTXAdmin, PresetsStorage)
+
+-- ============================================================================
+-- 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 (T15, T15 Pro, TX15, ST16, PL18)
+ else
+ return "sd" -- 480x272 (TX16S, MAX, Mk II)
+ 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)({
+ VTXAdmin = VTXAdmin,
+ bgOpacity = bgOpacity,
+ VTXDisplay = VTXDisplay,
+ WidgetLayout = WidgetLayout,
+})
+
+-- ============================================================================
+-- Widget lifecycle
+-- ============================================================================
+
+local lastBuilt6pos = -1
+
+local wgt = {
+ zone = zone,
+ options = options,
+}
+
+function wgt.background()
+ crsf:poll()
+ VTXAdmin.tick()
+end
+
+function wgt.refresh(_event, _touchState)
+ wgt.background()
+ if lvgl.isFullScreen() and VTXAdmin.lastPos ~= lastBuilt6pos then
+ lastBuilt6pos = VTXAdmin.lastPos
+ FullScreenUI.build()
+ end
+end
+
+function wgt.update(newOptions)
+ wgt.options = newOptions
+ if lvgl.isFullScreen() then
+ if VTXAdmin.isReady() then
+ VTXAdmin.syncDesiredFromState()
+ end
+ FullScreenUI.build()
+ lastBuilt6pos = VTXAdmin.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..92ad2cd
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/main.lua
@@ -0,0 +1,54 @@
+---------------------------------------------------------------------------
+-- 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/ELRS 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
+ if not _crsfSessionClass then
+ local getParams = loadScript("/SCRIPTS/ELRS/crsf_params.lua")
+ ---@diagnostic disable-next-line: need-check-nil
+ local params = getParams(_crsfSingleton)
+ local getSessionClass = loadScript("/SCRIPTS/ELRS/crsf_session.lua")
+ ---@diagnostic disable-next-line: need-check-nil
+ _crsfSessionClass = getSessionClass(_crsfSingleton, params)
+ end
+ local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua")
+ ---@diagnostic disable-next-line: need-check-nil
+ return loadable(zone, options, _crsfSingleton, _crsfSessionClass)
+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_storage.lua b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua
new file mode 100644
index 0000000..a75d884
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua
@@ -0,0 +1,101 @@
+---------------------------------------------------------------------------
+-- 6POS Preset Storage --
+-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with --
+-- (FileStorage); returns the PresetsStorage table. --
+-- --
+-- Pure settings store: the preset slots, sources and flags, and their --
+-- persistence as a key=value file. The automation that acts on them --
+-- lives in VTXAdmin. --
+---------------------------------------------------------------------------
+
+local FileStorage = ...
+
+-- Where the settings live on the SD card
+local PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt"
+
+-- The file layout, declared once: FileStorage writes these keys in this order.
+local SAVE_KEYS = { "enabled", "source", "autoPushVtx", "pushSource", "p1", "p2", "p3", "p4", "p5", "p6" }
+
+local PresetsStorage = {
+ 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)
+}
+
+--- 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
+
+--- Schema: enabled/autoPushVtx are "1"/"0" booleans, source/pushSource are
+--- source IDs, p1..p6 are "band,channel" pairs defaulting to Raceband R1..R6.
+function PresetsStorage.load()
+ local kv = FileStorage.read(PATH) or {}
+ local enabled = (kv.enabled == "1")
+ local source = tonumber(kv.source) or 0
+ local autoPushVtx = (kv.autoPushVtx == "1")
+ local pushSource = tonumber(kv.pushSource) or 0
+ local p = {}
+ for i = 1, 6 do
+ local val = kv[table.concat({ "p", i })]
+ if val then
+ local b, ch = splitBandChannel(val)
+ if b and ch then
+ p[i] = { band = b, channel = ch }
+ end
+ end
+ -- Fill missing positions with Raceband defaults (R1..R6)
+ if not p[i] then
+ p[i] = { band = 5, channel = i }
+ end
+ end
+ PresetsStorage.items = p
+ PresetsStorage.enabled = enabled
+ PresetsStorage.source = source
+ PresetsStorage.autoPushVtx = autoPushVtx
+ PresetsStorage.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 PresetsStorage.save()
+ print(table.concat({ "VTXAdmin: saving presets to ", PATH }))
+ local values = {
+ enabled = PresetsStorage.enabled and "1" or "0",
+ source = PresetsStorage.source,
+ autoPushVtx = PresetsStorage.autoPushVtx and "1" or "0",
+ pushSource = PresetsStorage.pushSource,
+ }
+ for i = 1, 6 do
+ values[table.concat({ "p", i })] =
+ table.concat({ PresetsStorage.items[i].band, ",", PresetsStorage.items[i].channel })
+ end
+ if FileStorage.write(PATH, SAVE_KEYS, values) then
+ print("VTXAdmin: presets saved OK")
+ else
+ print(table.concat({ "VTXAdmin: ERROR - could not open ", PATH, " for writing" }))
+ end
+end
+
+-- Initialize presets from file
+PresetsStorage.load()
+
+return PresetsStorage
diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/display.lua b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua
new file mode 100644
index 0000000..8a09480
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua
@@ -0,0 +1,249 @@
+---------------------------------------------------------------------------
+-- Minimized Display Layer --
+-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with --
+-- (VTXAdmin, PresetsStorage); returns VTXDisplay, WidgetLayout. --
+-- --
+-- VTXDisplay is the read-model the per-screen ui/ files consume: --
+-- zero-arg formatters passed by reference as LVGL text/color/visible --
+-- callbacks. WidgetLayout builds the minimized zone containers. --
+---------------------------------------------------------------------------
+
+local VTXAdmin, PresetsStorage = ...
+
+-- ============================================================================
+-- 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 VTXAdmin.isTuned()
+end
+
+--- True when a status message should be shown (loading, error, VTX off).
+function VTXDisplay.showStatus()
+ return not VTXAdmin.isTuned()
+end
+
+--- Band + channel string (e.g. "F6", "R4") when VTX is tuned, "" otherwise.
+function VTXDisplay.bandChannel()
+ if not VTXAdmin.isTuned() then
+ return ""
+ end
+ return table.concat({ VTXAdmin.state.bandLetter, VTXAdmin.state.channel })
+end
+
+--- Short status message for non-VTX states, "" when VTX is tuned.
+function VTXDisplay.statusText()
+ if not VTXAdmin.hasModule() then
+ return "No module"
+ end
+ if not VTXAdmin.isActive() then
+ return "Loading..."
+ end
+ if VTXAdmin.state.band == 0 then
+ return "VTX Off"
+ end
+ return ""
+end
+
+function VTXDisplay.powerShort()
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ return table.concat({ "P", VTXAdmin.state.power })
+end
+
+function VTXDisplay.powerLong()
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ return table.concat({ "Power ", VTXAdmin.state.power })
+end
+
+--- Pit mode state. "" when no power is set: ExpressLRS cannot send pit mode without it.
+--- A switch binding names the switch rather than asserting a position the folder name
+--- does not carry.
+function VTXDisplay.pitText()
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ if VTXAdmin.state.pitmode then
+ return "Pit Mode On"
+ end
+ if VTXAdmin.state.pitmodeAux then
+ return table.concat({ "Pit Mode ", VTXAdmin.state.pitmodeAux })
+ end
+ return "Pit Mode Off"
+end
+
+--- As pitText, but reports a disabled VTX instead of falling silent.
+function VTXDisplay.pitTextLong()
+ if VTXAdmin.isDisabled() then
+ return "VTX Disabled"
+ end
+ return VTXDisplay.pitText()
+end
+
+--- Terse flag for narrow tiers. Only a confirmed pit mode is worth the width.
+function VTXDisplay.pitShort()
+ return VTXAdmin.state.pitmode and "Pit" or ""
+end
+
+--- Red only when pit mode is confirmed on. An aux binding is not an assertion.
+function VTXDisplay.pitColor()
+ return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1
+end
+
+function VTXDisplay.detailLine()
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ return table.concat({ VTXDisplay.powerShort(), " ", VTXDisplay.pitText() })
+end
+
+function VTXDisplay.detailLong()
+ if VTXAdmin.isDisabled() then
+ return "VTX Disabled"
+ end
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ return table.concat({ VTXDisplay.powerLong(), " ", VTXDisplay.pitText() })
+end
+
+function VTXDisplay.mainColor()
+ if VTXAdmin.state.pitmode then
+ return RED
+ end
+ return COLOR_THEME_PRIMARY1
+end
+
+function VTXDisplay.build6posLabels(font)
+ if not VTXAdmin.hasModule() then
+ return {}
+ end
+ if not PresetsStorage.enabled then
+ return {}
+ end
+ local labels = {}
+ for i = 1, 6 do
+ local idx = i
+ labels[#labels + 1] = {
+ type = lvgl.LABEL,
+ font = font or SMLSIZE,
+ color = function()
+ return (VTXAdmin.lastPos == idx) and COLOR_THEME_PRIMARY1 or COLOR_THEME_DISABLED
+ end,
+ text = function()
+ local p = PresetsStorage.items[idx]
+ if p.band == 0 then
+ return table.concat({ idx, ":--" })
+ end
+ return table.concat({ idx, ":", VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel })
+ end,
+ }
+ end
+ return labels
+end
+
+--- Wrap 6POS labels in a row box. Shared by the one-row and two-row cheatsheets.
+local function cheatsheetRow(labels)
+ return {
+ type = lvgl.BOX,
+ flexFlow = lvgl.FLOW_ROW,
+ borderPad = 0,
+ flexPad = lvgl.PAD_TINY,
+ align = LEFT,
+ visible = VTXAdmin.hasModule,
+ children = labels,
+ }
+end
+
+--- Single row of all six presets, for tiers with only one line to spare.
+function VTXDisplay.buildCheatsheet(font)
+ local labels = VTXDisplay.build6posLabels(font)
+ if #labels == 0 then
+ return nil
+ end
+ return cheatsheetRow(labels)
+end
+
+--- Two rows, 1-3 over 4-6. Fits narrow zones and reads larger where height allows.
+function VTXDisplay.buildCheatsheetRows(font)
+ local labels = VTXDisplay.build6posLabels(font)
+ 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
+ return cheatsheetRow(row1), cheatsheetRow(row2)
+end
+
+return VTXDisplay, WidgetLayout
diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua
new file mode 100644
index 0000000..9a88631
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua
@@ -0,0 +1,323 @@
+---------------------------------------------------------------------------
+-- Full-Screen Editor --
+-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with --
+-- (VTXAdmin, PresetsStorage); returns the FullScreenUI table. --
+-- --
+-- One layout for every screen size: VTX Settings editing --
+-- VTXAdmin.desired, the Send VTx button, 6POS Quick Change and preset --
+-- slot rows saving through PresetsStorage, and the no-module checklist. --
+---------------------------------------------------------------------------
+
+local VTXAdmin, PresetsStorage = ...
+
+local FullScreenUI = {}
+
+-- ============================================================================
+-- 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, visibleFn)
+ local row = container:rectangle({
+ w = lvgl.PERCENT_SIZE + 100,
+ thickness = 0,
+ flexFlow = lvgl.FLOW_ROW,
+ flexPad = 0,
+ visible = visibleFn,
+ })
+
+ 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, visibleFn, hint)
+ local ctrl = createRow(container, label, hint, visibleFn)
+ 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)
+-- ============================================================================
+
+function FullScreenUI.build()
+ lvgl.clear()
+
+ local d = VTXAdmin.desired
+
+ local pg = lvgl.page({
+ title = "ExpressLRS",
+ subtitle = function()
+ if VTXAdmin.isActive() then
+ return "VTX Administrator"
+ end
+ return VTXAdmin.statusText
+ end,
+ back = function()
+ lvgl.exitFullScreen()
+ end,
+ })
+
+ -- No module — show checklist instead of controls (matches expresslrs.lua NoModuleDialog)
+ if not VTXAdmin.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,
+ })
+
+ -- 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
+ VTXAdmin.writeConfig()
+ end)
+
+ createNumberRow(fields, "Channel", 1, 8, function()
+ return d.channel
+ end, function(v)
+ d.channel = v
+ end, function(v)
+ d.channel = v
+ VTXAdmin.writeConfig()
+ end)
+
+ createNumberRow(fields, "Power Level", 0, 8, function()
+ return d.power
+ end, function(v)
+ d.power = v
+ end, function(v)
+ d.power = v
+ VTXAdmin.writeConfig()
+ end, function(v)
+ return v == 0 and "-" or tostring(v)
+ end)
+
+ -- Pit mode rides on the power byte, so ExpressLRS hides it while power is "-".
+ createToggleRow(fields, "Pit Mode", function()
+ return d.pitmode
+ end, function(v)
+ d.pitmode = v
+ VTXAdmin.writeConfig()
+ end, function()
+ return d.power > 0
+ 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 VTXAdmin.isSending() then
+ return "Sending..."
+ end
+ return "Send VTx"
+ end,
+ w = lvgl.PERCENT_SIZE + 99,
+ press = function()
+ VTXAdmin.writeConfig()
+ VTXAdmin.pushToVtx()
+ end,
+ active = function()
+ return VTXAdmin.isReady()
+ end,
+ })
+
+ -- 6POS Quick Change section
+ createSectionHeader(fields, "6POS Quick Change")
+
+ createToggleRow(fields, "Enabled", function()
+ return PresetsStorage.enabled and 1 or 0
+ end, function(v)
+ PresetsStorage.enabled = (v == 1)
+ PresetsStorage.save()
+ end)
+
+ createSourceRow(fields, "Source", function()
+ return PresetsStorage.source
+ end, function(v)
+ PresetsStorage.source = v or 0
+ PresetsStorage.save()
+ end, lvgl.SRC_STICK + lvgl.SRC_POT + lvgl.SRC_SWITCH)
+
+ createToggleRow(fields, "Auto Push to VTX", function()
+ return PresetsStorage.autoPushVtx and 1 or 0
+ end, function(v)
+ PresetsStorage.autoPushVtx = (v == 1)
+ PresetsStorage.save()
+ end, nil, "Send to the VTX as soon as the 6POS position changes. When off, use the trigger below.")
+
+ createSourceRow(
+ fields,
+ "Send VTx Trigger",
+ function()
+ return PresetsStorage.pushSource
+ end,
+ function(v)
+ PresetsStorage.pushSource = v or 0
+ VTXAdmin.pushLastHigh = nil
+ PresetsStorage.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 = { "--", "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 PresetsStorage.items[idx].band + 1
+ end,
+ set = function(v)
+ PresetsStorage.items[idx].band = v - 1
+ PresetsStorage.save()
+ end,
+ })
+
+ ctrl:numberEdit({
+ min = 1,
+ max = 8,
+ get = function()
+ return PresetsStorage.items[idx].channel
+ end,
+ set = function(v)
+ PresetsStorage.items[idx].channel = v
+ PresetsStorage.save()
+ end,
+ visible = function()
+ return PresetsStorage.items[idx].band > 0
+ end,
+ })
+ end
+end
+
+return FullScreenUI
diff --git a/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua
new file mode 100644
index 0000000..05f2c86
--- /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 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, cheatsheet = STDSIZE },
+ half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+ full = { hero = MIDSIZE, detail = 0, cheatsheet = STDSIZE },
+}
+
+-- ============================================================================
+-- Minimized layout builders (by widget height tier)
+-- ============================================================================
+
+local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({
+ VTXDisplay = VTXDisplay,
+})
+
+--- 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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..10fa307
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua
@@ -0,0 +1,336 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - UI for 320x480 (Portrait) --
+-- FlySky EL18 — vertical screen, widget zones differ significantly --
+---------------------------------------------------------------------------
+
+local ctx = ...
+local VTXAdmin = ctx.VTXAdmin
+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, cheatsheet = STDSIZE },
+ half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+ full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+}
+
+-- ============================================================================
+-- Minimized display helpers (portrait-specific overrides)
+-- ============================================================================
+
+--- Shorter detail line for narrow portrait screen.
+local function detailLine()
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ local pit = VTXAdmin.state.pitmode and " Pit" or ""
+ return table.concat({ VTXDisplay.powerShort(), pit })
+end
+
+--- Shorter long-form detail line for narrow portrait screen.
+local function detailLong()
+ if VTXAdmin.isDisabled() then
+ return "VTX Disabled"
+ end
+ if not VTXAdmin.hasPower() then
+ return ""
+ end
+ local pit = VTXAdmin.state.pitmode and " Pit" or ""
+ return table.concat({ VTXDisplay.powerLong(), pit })
+end
+
+-- ============================================================================
+-- Minimized layout builders (by widget height tier)
+-- ============================================================================
+
+local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({
+ VTXDisplay = VTXDisplay,
+})
+
+--- 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 = VTXDisplay.pitColor,
+ text = VTXDisplay.pitText,
+ },
+ },
+ },
+ }
+ if w < 200 then
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows()
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 = detailLong,
+ },
+ }
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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,
+ },
+ }
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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..2226fd0
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua
@@ -0,0 +1,286 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - UI for 480x272 (SD) --
+-- Standard definition landscape (TX16S, TX16S MAX, TX16S Mark II) --
+---------------------------------------------------------------------------
+
+local ctx = ...
+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, cheatsheet = SMLSIZE },
+ half = { hero = BOLD, detail = SMLSIZE, cheatsheet = STDSIZE },
+ full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+}
+
+-- ============================================================================
+-- Minimized layout builders (by widget height tier)
+-- ============================================================================
+
+local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({
+ VTXDisplay = VTXDisplay,
+})
+
+--- 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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..7e339b2
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua
@@ -0,0 +1,299 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - UI for 480x320 (SD Tall) --
+-- Jumper T15, T15 Pro, TX15, ST16, PL18 --
+---------------------------------------------------------------------------
+
+local ctx = ...
+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, cheatsheet = SMLSIZE },
+ half = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+ full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = STDSIZE },
+}
+
+-- ============================================================================
+-- Minimized layout builders (by widget height tier)
+-- ============================================================================
+
+local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({
+ VTXDisplay = VTXDisplay,
+})
+
+--- 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 = VTXDisplay.pitColor,
+ text = VTXDisplay.pitText,
+ }
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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..f366ec7
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua
@@ -0,0 +1,318 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - UI for 320x240 (Small) --
+-- Small color LCD (PA01) --
+---------------------------------------------------------------------------
+
+local ctx = ...
+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, cheatsheet = SMLSIZE },
+ half = { hero = BOLD, detail = SMLSIZE, cheatsheet = SMLSIZE },
+ full = { hero = MIDSIZE, detail = SMLSIZE, cheatsheet = SMLSIZE },
+}
+
+-- ============================================================================
+-- Minimized layout builders (by widget height tier)
+-- ============================================================================
+
+local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({
+ VTXDisplay = VTXDisplay,
+})
+
+--- 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 = VTXDisplay.pitColor,
+ text = VTXDisplay.pitText,
+ },
+ }
+ 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 = VTXDisplay.pitShort,
+ },
+ },
+ },
+ }
+ 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 = VTXDisplay.pitColor,
+ text = VTXDisplay.pitText,
+ },
+ },
+ }
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.third.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 = VTXDisplay.pitColor,
+ text = VTXDisplay.pitTextLong,
+ },
+ },
+ },
+ }
+ local cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.half.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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 cs1, cs2 = VTXDisplay.buildCheatsheetRows(WidgetUI.fonts.full.cheatsheet)
+ if cs1 then
+ rows[#rows + 1] = cs1
+ rows[#rows + 1] = cs2
+ 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..58816ec
--- /dev/null
+++ b/src/WIDGETS/ELRSVTXAdmin/ui/topbar.lua
@@ -0,0 +1,48 @@
+---------------------------------------------------------------------------
+-- VTX Administrator Widget - Shared Top Bar UI --
+-- Used by all screen-specific UI files for the top bar layout. --
+---------------------------------------------------------------------------
+
+local ctx = ...
+local VTXDisplay = ctx.VTXDisplay
+
+local TopBarUI = {}
+
+--- Top bar: label over value, matching EdgeTX's stock status bar widgets.
+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 = COLOR_THEME_PRIMARY2,
+ text = "VTX",
+ },
+ {
+ type = lvgl.LABEL,
+ align = CENTER,
+ font = SMLSIZE,
+ color = COLOR_THEME_PRIMARY2,
+ text = function()
+ if VTXDisplay.showStatus() then
+ return "--"
+ end
+ return VTXDisplay.bandChannel()
+ end,
+ },
+ },
+ },
+ })
+end
+
+return TopBarUI