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..531c909 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.luac +src/WIDGETS/ELRSVTXAdmin/presets.txt +src/SCRIPTS/TOOLS/ExpressLRSBind/history.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..3be0792 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,135 @@ +# 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 and bind phrase manager for ExpressLRS on EdgeTX radios. Both work on 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. -![downloadlua](https://user-images.githubusercontent.com/68074253/129203116-c1234719-3e8c-4cbf-a391-b7fb8dc0262d.png) +## Installation + +Copy the contents of the `src/` directory to the **root** of your radio's SD card, preserving the directory structure. Delete any old ELRS scripts (`ELRS.lua`, `elrsV2.lua`, `elrsV3.lua`, `expresslrs.lua` and their `.luac` counterparts) from `SCRIPTS/TOOLS/`. + +When done, your SD card should contain: + +``` +SCRIPTS/ + ELRS/ + crsf.lua -- shared CRSF protocol library + crsf_params.lua -- parameter codec (tool, VTX Admin) + crsf_session.lua -- stateful parameter client (tool, VTX Admin) + msp.lua -- MSP-over-CRSF codec (bind tool) + defer.lua -- deferred-callback timer (bind tool) + loader.lua -- GC-guarded script loader (both tools) + edgetx_version.lua -- EdgeTX minimum-version gate (both tools) + ui/ + lcd/ + text_edit.lua -- BW text editor (bind tool) + alert.lua -- BW alert screen (both tools) + lvgl/ + dialogs.lua -- color startup dialogs (both tools) + sensors.lua -- telemetry sensor reader + file_storage.lua -- key=value file persistence + 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 + ExpressLRSBind/ + main.lua -- entry point + history_storage.lua -- bind phrase history persistence + ui/ + lvgl.lua -- color LCD UI (LVGL) + lcd.lua -- black & white LCD UI +WIDGETS/ + ELRSTelemetry/ + main.lua -- entry point + telemetry.lua -- link state: drain, device info, model match, sensor snapshot + rf_modes.lua -- RF mode names and RSSI floors, by firmware version + loadable.lua -- per-instance wiring and lifecycle + ui/ + display.lua -- read model shared by every layout below + fullscreen.lua -- full-screen page + topbar.lua -- top-bar layout + sd.lua sd_tall.lua hd.lua portrait.lua small.lua + ELRSVTXAdmin/ + main.lua -- entry point + loadable.lua -- VTX config client and wiring + presets_storage.lua -- preset slot persistence + presets.txt + ui/ + display.lua -- read model shared by every layout below + fullscreen.lua -- full-screen editor + topbar.lua -- top-bar layout + sd.lua sd_tall.lua hd.lua portrait.lua small.lua +``` + +The shared library `SCRIPTS/ELRS/` is required by both tools 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. + +ExpressLRS Configuration Tool
+ +ExpressLRS Configuration Tool + +## ExpressLRS Bind Phrase Manager + +The bind tool (`SCRIPTS/TOOLS/ExpressLRSBind/`) sets the bind phrase -- or a raw UID entered as +comma-separated bytes -- on the transmitter, the receiver, or both in one sequence, reads the +current UID back for verification, and can put the TX in bind mode or unbind a connected receiver. +The last five phrases are kept as a pick-and-send history. Setting the phrase over MSP requires +**ExpressLRS 4.1+** on the device. + +ExpressLRS Bind Phrase Manager
+ +ExpressLRS Bind Phrase Manager + +## Widgets + +Both widgets running side-by-side on the home screen: + +ELRS Widgets + +## ELRS Telemetry Widget + +The telemetry widget (`WIDGETS/ELRSTelemetry/`) displays real-time link statistics on your home screen: link quality, RSSI, range, RF mode, TX power, battery voltage, current, GPS, and flight mode. It supports multiple screen resolutions (800x480, 480x320, 480x272, 320x480, 320x240). + +ELRS Telemetry Widget + +## VTX Administrator Widget + +The VTX Administrator widget (`WIDGETS/ELRSVTXAdmin/`) provides control over your video transmitter settings -- band, channel, power level, and pit mode -- directly from your radio telemetry screen. It also supports 6POS quick change for rapid VTX channel switching via a 6POS switch. Presets are organised into six collections selected in the widget's editor, so your home field and a race event can each keep their own set of channels. + +VTX Administrator Widget + +## 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+ | + +The bind phrase manager additionally requires **ExpressLRS 4.1+** on the device for its MSP +configuration support; on older firmware it reports "No response (needs ELRS 4.1+)". diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..8c77252 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,164 @@ +# Development + +This document covers the internal architecture of the ExpressLRS tools 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 + +The configuration tool, `SCRIPTS/TOOLS/ExpressLRS/`: + +| 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 bind phrase manager, `SCRIPTS/TOOLS/ExpressLRSBind/` (sets the bind phrase / UID over MSP; +the device side requires ExpressLRS 4.1+, and a pre-4.1 device simply never answers -- the tool's +bounded UID retry reports that instead of polling forever): + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and the App layer: target selection (TX/RX/Both), phrase-vs-raw-UID parsing, the two-step Both sequence (RX first -- writing its phrase drops it off the link -- then TX), the bounded UID read retry, and the frame router over `crsf.drain` | +| `history_storage.lua` | The last five phrases, newest first, persisted through `file_storage.lua` as indexed keys `h1`..`h5` | +| `ui/lvgl.lua` | Color LCD interface | +| `ui/lcd.lua` | BW LCD interface (line list, phrase editing through `ui/lcd/text_edit.lua`) | + +Both tools pick their UI chunk at runtime -- `local useLvgl = (lvgl ~= nil)` -- and load exactly one +of `ui/lvgl.lua` or `ui/lcd.lua`; there are no per-radio builds. + +The telemetry widget, `WIDGETS/ELRSTelemetry/`: + +| Module | Purpose | +|--------|---------| +| `main.lua` | Entry point and the EdgeTX widget descriptor. Bootstraps the singletons into globals and resets the model-scoped state when the model changes | +| `telemetry.lua` | Everything the widget knows about the link, in one owner: the frame drain, the DEVICE_INFO cache, the model-match status and the rules for when it may be asked for, the per-tick sensor snapshot, and every value derived from it. The only file in the widget that sees `crsf` | +| `rf_modes.lua` | Packet-rate names and rated RSSI floors, keyed by the module's firmware major version. Pure data plus its selector, apart from the policy because it versions on ExpressLRS's release clock | +| `loadable.lua` | One per placed widget: picks the layout for the screen, wires the components, drives them from the widget callbacks. Owns no state | +| `ui/display.lua` | The read model: zero-argument formatters the layouts pass to LVGL as `text`/`color` callbacks, plus `WidgetLayout`. The whole vocabulary the view has | +| `ui/fullscreen.lua` | The full-screen page, one layout for every screen size. Loaded on first entry | +| `ui/.lua` | Minimized layout per screen size, each with its own breakpoints, fonts and height tiers | +| `ui/topbar.lua` | Top-bar layout, shared by every screen file | + +`WIDGETS/ELRSVTXAdmin/` has the same shape, with `loadable.lua` holding the domain (it is per +instance, being a config client rather than shared link state) and `presets_storage.lua` alongside it. + +Two rules hold across both widgets. The per-screen `ui/.lua` files are deliberately +self-contained -- own breakpoints, own fonts, own tier builders -- so changing one screen cannot break +another; shared *presentation* goes in `ui/display.lua` instead. And no `ui/` file receives the CRSF +transport: the view asks the read model, which asks the domain. + +The telemetry widget's state is shared by every instance of it, because it describes the radio's link +rather than a widget. That costs three things, all stated in `telemetry.lua`'s header: `drain()` runs +per instance and ungated (each instance owns a firmware pop queue only it can empty), `update()` +samples at most once per tick (or the range smoother steps once per instance per frame), and the frame +handlers stay pure assignment (every instance is delivered its own copy of each frame, so the +singleton decodes each one N times on a radio and once in the simulator). + +The tools build on the shared `SCRIPTS/ELRS/` library, which the widgets use too: + +| Module | Purpose | +|--------|---------| +| `SCRIPTS/ELRS/crsf.lua` | CRSF constants, telemetry transport (`pop`/`drain`/`push`), module detection, derived link state (`hasTelemetry`, refreshed as a drain empties the queue), stateless frame decoders (`decodeDeviceInfo`, `decodeElrsStatus`, `isElrsV1Frame`) | +| `SCRIPTS/ELRS/crsf_params.lua` | Opt-in parameter codec: `PARAMETER_SETTINGS_ENTRY` chunk reassembly over a caller-owned rx table and per-type decode, plus encoders that return `PARAMETER_READ`/`WRITE`, command-step and suppress-critical-errors frames for the caller to push. Loaded by the tool and the VTX Admin widget | +| `SCRIPTS/ELRS/crsf_session.lua` | Opt-in stateful parameter client (`CRSFSession.new`, multi-instance): field store, load queue and retry scheduler, paced write queue, command state machine, and optional device discovery, link status and ELRS 1.x detection. Loaded by the tool and the VTX Admin widget | +| `SCRIPTS/ELRS/msp.lua` | Opt-in MSP-over-CRSF codec: stateless encoders returning `(frameType, payload)` for `MSP_REQ`/`MSP_WRITE` and decoders for single-frame v1 `MSP_RESP`, plus the ELRS `RXTX_CONFIG` UID/phrase helpers. Loaded only by the bind tool | +| `SCRIPTS/ELRS/defer.lua` | Single-slot `setTimeout`/`poll` timer; scheduling replaces the pending callback, which is what cancels a stale retry when a new action starts. Loaded only by the bind tool | +| `SCRIPTS/ELRS/ui/lcd/text_edit.lua` | BW text editor replicating the firmware's `editName()` model-name semantics (rotary cycles the char, ENTER advances, long ENTER toggles case or commits on a space). Loaded only by the bind tool's BW UI. `ui//` is the library's home for shared UI components, mirroring the tools' own `ui/` split | +| `SCRIPTS/ELRS/ui/lcd/alert.lua` | BW full-screen alert (MIDSIZE title, body lines, optional bottom action labels). Loaded by both tools' BW UIs | +| `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | The color-LCD startup dialogs a tool can raise before it has a page -- the version gate and the missing-module notice. Both are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs | +| `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | +| `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | +| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue`. A cached ID addresses a slot in the model that was loaded when it was resolved, so a consumer that survives a model change must call `crsf.resetSensorCache()` on that edge | +| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | +| `SCRIPTS/ELRS/shim.lua` | `table.concat` polyfill for BW radios | + +Frames are consumed pull-style. `crossfireTelemetryPop()` is destructive per script instance, and +the firmware delivers every widget instance its own copy of each incoming frame, so **each script +instance has exactly one draining consumer**: the config tool and the VTX Admin widget drain through +`session:drain()`, the telemetry widget through `Telemetry.drain()`, and the bind tool through +`crsf.drain(App, App.onFrame)`. A future widget needing two consumers must pop once and route the +frames itself. `reassemble()` callers pass the field id they +are waiting for (strict), or `data[3]` to accept any field from their device (`acceptUnsolicited`, +used by VTX Admin so sibling instances stay in sync from each other's answers). + +Nothing in `crsf.lua` or `crsf_params.lua` mutates a frame's data table, with one deliberate +exception: the link-layer `readString` in `crsf.lua` converts bytes to chars in place, which is safe +because each frame type has exactly one string-decoding consumer. The codec stays read-only because +`reassemble()`'s single-frame fast path hands the caller's frame back as the decode buffer. + +## 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`. + +**Files:** `src/SCRIPTS/CRSFSimulator/csrfsimulator.lua` (the mock), `src/SCRIPTS/CRSFSimulator/shim.lua` (its `table.concat`/`table.remove`/`charsToString` helpers) + +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. + +Delivery mirrors the firmware's per-widget queue replication: frames append to a shared log and every consumer (the identity passed to `pop()`) advances its own cursor over it, receiving its own copy of each frame. Several widgets can therefore drain "their" queues concurrently against the one mock, just as they do against the firmware's real per-instance queues. + +### 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`, `getSensorValue` and `hasCrsfModule` functions with the mock implementations. The tool and the widgets both talk to CRSF through that library, so the one mock instance 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. | + +### MSP bind traffic + +The mock answers the bind tool's MSP `RXTX_CONFIG` traffic: a UID read (`MSP_REQ`) is served from a +per-device `mspUid` table -- the TX and RX deliberately start with different UIDs so a fresh `normal` +run shows a mismatch that setting both to one phrase visibly fixes -- and a phrase write +(`MSP_WRITE`) rederives the target's UID through a deterministic pseudo-hash, so equal phrases give +equal UIDs (the property the Both flow demonstrates; the bytes need not match the firmware's MD5). +The RX answers only while the scenario keeps it reachable, which is what exercises the tool's +bounded retry, and writes are unacknowledged just like the real firmware. `FRAMETYPE_COMMAND` +bind/unbind requests are log-only. + +`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..0420184 --- /dev/null +++ b/edgetx.yml @@ -0,0 +1,61 @@ +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/tool_bind.png + - screenshots/tool_bind_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 + + - name: ExpressLRSBind + path: SCRIPTS/TOOLS/ExpressLRSBind + depends: + - ELRS + exclude: + - history.txt + +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_bind.png b/screenshots/tool_bind.png new file mode 100644 index 0000000..8aeb6f8 Binary files /dev/null and b/screenshots/tool_bind.png differ diff --git a/screenshots/tool_bind_bw.png b/screenshots/tool_bind_bw.png new file mode 100644 index 0000000..6932224 Binary files /dev/null and b/screenshots/tool_bind_bw.png differ 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..b7d58df 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..6b31ccb 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..8377dec 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..88b9a4d --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -0,0 +1,1945 @@ +-- ============================================================================ +-- 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 simulator's own compat layer. +local shim = loadScript("/SCRIPTS/CRSFSimulator/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). One ELRS_STATUS request on each +-- connect edge and then ~1 Hz for as long as the mismatch +-- stands; the warning must clear while the link is down. +-- "mismatch_recovery" TX + RX connected on a strong link with the model +-- mismatch flag set, clearing ~10 s in while RQly never +-- leaves 95. The transition a once-per-connection status +-- latch can never observe: the module only reports the +-- verdict when asked, so the warning clears only if the +-- widget keeps asking while it stands. +-- "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. +-- "unrated_rate" TX + RX connected on a packet rate ExpressLRS publishes +-- no receiver sensitivity for. There is no floor to +-- measure RSSI against, so anything drawn against one has +-- to fall back instead of scaling off a guess. +-- "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. +-- +-- In every connected scenario the armed flag additionally follows CH5 (AUX1, +-- the ELRS arm channel): drive it high to arm mid-session, low to disarm. +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, + FRAMETYPE_COMMAND = 0x32, + FRAMETYPE_MSP_REQ = 0x7A, + FRAMETYPE_MSP_RESP = 0x7B, + FRAMETYPE_MSP_WRITE = 0x7C, + + -- MSP-over-CRSF (single-frame v1: version 1 | start-of-frame | seq 0) + MSP_HEADER_V1 = 0x30, + MSP_ELRS_RXTX_CONFIG = 0x2D, + MSP_RXTX_UID = 0x00, + MSP_RXTX_BIND_PHRASE = 0x01, + + -- 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 }, +} + +-- ============================================================================ +-- Packet delivery: a frame log with per-consumer cursors +-- +-- Real firmware replicates every incoming frame into each widget instance's +-- private queue, so every consumer sees every frame. The mock emulates that: +-- frames append to a shared log, and each consumer (the identity passed to +-- pop()) advances its own cursor over it. A new consumer starts at the +-- current tail, matching the firmware's lazy queue creation. Each delivery +-- hands out a fresh copy of the data table -- consumers decode in place, +-- exactly as they may with the firmware's per-queue copies. +-- ============================================================================ + +local frameLog = {} +local logTotal = 0 +local logPruned = 0 +local cursors = setmetatable({}, { __mode = "k" }) +local defaultConsumer = {} + +-- 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 + +-- Drop log entries every cursor has passed +local function pruneLog() + local minCursor = logTotal + for _, c in pairs(cursors) do + if c < minCursor then + minCursor = c + end + end + for i = logPruned + 1, minCursor do + frameLog[i] = nil + end + logPruned = minCursor +end + +local function queuePush(command, data) + logTotal = logTotal + 1 + frameLog[logTotal] = { command = command, data = data } + pruneLog() +end + +local function queuePushDeferred(command, data) + deferredQueue[#deferredQueue + 1] = { command = command, data = data } +end + +-- Deliver the frame at index to a consumer as a fresh data-table copy +local function deliver(consumer, index) + cursors[consumer] = index + local pkt = frameLog[index] + local data = {} + for i = 1, #pkt.data do + data[i] = pkt.data[i] + end + return pkt.command, data +end + +local function queuePop(consumer) + consumer = consumer or defaultConsumer + local cursor = cursors[consumer] + if cursor == nil then + cursor = logTotal + cursors[consumer] = cursor + end + + -- Serve the next unread log entry first + if cursor < logTotal then + deferredReady = false + return deliver(consumer, cursor + 1) + end + + -- At the tail: serve deferred packets only after a nil has been returned + -- (next poll cycle). Promotion appends to the log, so every consumer + -- sees the deferred frame. + if deferredReady and #deferredQueue > 0 then + local pkt = shim.tableRemove(deferredQueue, 1) + ---@diagnostic disable-next-line: need-check-nil + queuePush(pkt.command, pkt.data) + return deliver(consumer, cursors[consumer] + 1) + 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 = "" }, + -- Power level 2, pit mode off: a VTX whose power ExpressLRS is managing. + -- At "-" the folder name drops the power and pit mode segments entirely and + -- the Pitmode field is hidden, so nothing downstream has a power level or a + -- pit state to render -- which makes it the wrong default for a mock whose + -- job is to exercise the display. + { + id = 13, + parent = 10, + type = CRSF.TEXT_SELECTION, + name = "Pwr Lvl", + options = "-;1;2;3;4;5;6;7;8", + value = 2, + 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 = "Antenna Mode", + options = "Antenna 1;Antenna 2;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 + +-- Mismatch recovery scenario timing: how long the mismatch stands before the +-- module starts answering that it is gone, with the link untouched throughout. +local mismatchClearDelay = 1000 -- ~10 seconds (getTime() ticks at 10ms) + +-- 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 + +-- CH5 is AUX1, the ELRS arm channel: armed while it is high. +-- getOutputValue is 0-based, so 4 reads CH5. +local function isArmed() + return (getOutputValue(4) or 0) > 0 +end + +local function getElrsFlags() + local flags + if config.scenario == "reconnect" then + flags = isRxAvailable() and 0x01 or 0x00 + elseif config.scenario == "model_mismatch" or config.scenario == "mismatch_cycle" then + flags = 0x05 -- connected + model mismatch + elseif config.scenario == "mismatch_recovery" then + -- Recomputed per answer, exactly as sendELRSstatus() does: the mismatch + -- ends on its own with the link still up, and nothing announces it. + local cleared = startTime ~= nil and getTime() - startTime >= mismatchClearDelay + flags = cleared and 0x01 or 0x05 + elseif config.scenario == "armed" then + flags = 0x09 -- connected + armed + elseif config.scenario == "critical_error" then + if criticalErrorsSuppressed then + flags = 0x01 -- connected, critical bits suppressed + else + flags = 0x41 -- connected + baud rate error (critical) + end + elseif + config.scenario == "normal" + or config.scenario == "slow_loading" + or config.scenario == "single_antenna" + or config.scenario == "weak_link" + or config.scenario == "unrated_rate" + then + flags = 0x01 -- connected + else + flags = 0x00 -- no telemetry + end + -- Sampled per status answer while connected, like handset->IsArmed() + -- in sendELRSstatus() + if bit32.btest(flags, 0x01) and isArmed() then + flags = bit32.bor(flags, 0x08) + end + return flags +end + +-- Highest set bit wins, matching the messages[] scan (7..0) in +-- sendELRSstatus(); the suppressed critical bit is already off in flags. +local function getElrsFlagsInfo(flags) + if bit32.btest(flags, 0x40) then + return "Baud rate too low" + elseif bit32.btest(flags, 0x08) then + return "[ ! Armed ! ]" + elseif bit32.btest(flags, 0x04) then + return "Model Mismatch" + 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 + +-- ============================================================================ +-- MSP bind UID state +-- ============================================================================ + +-- Per-device bind UID, read and written over MSP RXTX_CONFIG. TX and RX +-- deliberately start different so a fresh "normal" run shows a mismatch +-- that setting both to one phrase visibly fixes. +local mspUid = { + [CRSF.ADDRESS_TX] = { 13, 213, 105, 32, 0, 1 }, + [CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 2 }, +} + +--- Derive a deterministic 6-byte UID from bind-phrase bytes: the same +-- phrase always yields the same UID, so a phrase written to both devices +-- produces matching UIDs. Equality is the property the tool demonstrates; +-- the bytes themselves need not match the firmware's MD5 derivation. +local function deriveUid(chars) + local uid = { 0, 0, 0, 0, 0, 0 } + local acc = 0 + for i = 1, #chars do + acc = (acc + chars[i] * i) % 251 + local slot = (i - 1) % 6 + 1 + uid[slot] = (uid[slot] + acc + chars[i]) % 256 + end + return uid +end + +--- Build an MSP_RESP payload answering a RXTX_CONFIG/UID read. +-- Layout mirrors the request: header, size (subcmd + 6 bytes), fn, subcmd. +local function encodeMspUidResponse(deviceId, destAddr, uid) + return { + destAddr, + deviceId, + CRSF.MSP_HEADER_V1, + 7, + CRSF.MSP_ELRS_RXTX_CONFIG, + CRSF.MSP_RXTX_UID, + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + } +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(flags) + 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 + elseif command == CRSF.FRAMETYPE_MSP_REQ then + -- MSP read: data = { deviceId, handsetId, header, size, fn, subcmd } + local deviceId = data[1] + local replyTo = data[2] or CRSF.ADDRESS_HANDSET + if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and data[6] == CRSF.MSP_RXTX_UID then + if deviceId == CRSF.ADDRESS_TX then + queuePush(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId])) + elseif deviceId == CRSF.ADDRESS_RX and isRxAvailable() then + -- Relayed over the air: arrives in the next poll cycle. An absent RX + -- answers nothing, which is what drives the tool's bounded retry. + queuePushDeferred(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId])) + end + end + return true + elseif command == CRSF.FRAMETYPE_MSP_WRITE then + -- MSP write: data = { deviceId, handsetId, header, size, fn, subcmd, ... }. + -- The firmware sends no acknowledgement; the tool re-reads the UID. + local deviceId = data[1] + local reachable = deviceId == CRSF.ADDRESS_TX or (deviceId == CRSF.ADDRESS_RX and isRxAvailable()) + if data[5] == CRSF.MSP_ELRS_RXTX_CONFIG and reachable then + if data[6] == CRSF.MSP_RXTX_BIND_PHRASE then + -- size counts subcmd + phrase bytes, so the phrase ends at data[5 + size] + local chars = {} + for i = 7, 5 + (data[4] or 0) do + chars[#chars + 1] = data[i] + end + mspUid[deviceId] = deriveUid(chars) + elseif data[6] == CRSF.MSP_RXTX_UID then + local uid = {} + for i = 1, 6 do + uid[i] = data[6 + i] or 0 + end + mspUid[deviceId] = uid + end + end + return true + elseif command == CRSF.FRAMETYPE_COMMAND then + -- Bind/unbind requests. Log-only: the push line above already records + -- the destination, and the mock has no bound-state to change. + return true + end + + -- Unknown command - ignore + return true +end + +-- ============================================================================ +-- mockPop: Returns next queued packet or nil +-- ============================================================================ + +local function mockPop(consumer) + 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(consumer) + + -- 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 } + +-- TQly/TRSS are the downlink pair: the RX->TX telemetry path, reported by the +-- handset's own receiver. They run a few dB behind the uplink in every +-- scenario because the receiver transmits at a fraction of the module's power, +-- which is the asymmetry the widget exists to show. +local scenarioTelemetry = { + normal = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + TQly = 100, + TRSS = -95, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, + -- 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, + TQly = 100, + TRSS = -91, + RxBt = 15.1, + Curr = 11.0, + FM = "ACRO", + Sats = 11, + GSpd = 22.4, + Alt = 120, + GPS = { lat = 54.6901, lon = 25.2712 }, + }, + armed = { + TPWR = 250, + RFMD = 7, + ["1RSS"] = -78, + ["2RSS"] = -82, + RQly = 100, + ANT = 0, + TQly = 100, + TRSS = -83, + RxBt = 14.8, + Curr = 28.5, + FM = "ACRO", + Sats = 14, + GSpd = 42.7, + Alt = 85, + GPS = { lat = 54.7050, lon = 25.3100 }, + }, + -- A packet rate ExpressLRS publishes no sensitivity figure for (v3 index 18, + -- "9K1000", carried in the tables as 0). There is no floor to measure + -- against, so everything derived from one has to fall back rather than draw + -- a bar against a guessed number. + unrated_rate = { + TPWR = 250, + RFMD = 18, + ["1RSS"] = -79, + ["2RSS"] = -84, + RQly = 98, + ANT = 0, + TQly = 100, + TRSS = -88, + RxBt = 15.4, + Curr = 9.8, + FM = "ACRO", + Sats = 10, + GSpd = 18.2, + Alt = 96, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, + -- 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, + TQly = 100, + TRSS = -61, + 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, + TQly = 100, + TRSS = -61, + RxBt = 15.8, + Curr = 0.5, + }, + -- Same signal as model_mismatch, and deliberately no sensorToggle entry: the + -- mismatch bit is the only thing that may move, so RQly stays at 95 and the + -- connection never edges. + mismatch_recovery = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + ANT = 1, + TQly = 100, + TRSS = -61, + 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, + TQly = 62, + TRSS = -97, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 9, + GSpd = 31.0, + Alt = 210, + GPS = { lat = 54.6600, lon = 25.2400 }, + }, + reconnect = { + -- Same as normal; only served when isRxAvailable() is true + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + TQly = 100, + TRSS = -95, + 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, + TQly = 100, + TRSS = -95, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, + critical_error = { + -- Same link as normal; only the ELRS status flags differ. + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + ANT = 1, + TQly = 100, + TRSS = -95, + RxBt = 15.2, + Curr = 12.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, +} + +-- Jitter ranges for sensors that fluctuate in real life. +-- Sensors not listed (TPWR, RFMD, ANT, FM, Sats, GPS) stay static. +local sensorJitter = { + ["1RSS"] = 3, -- +/- 3 dBm + ["2RSS"] = 3, + TRSS = 3, + RQly = 2, -- +/- 2% + TQly = 2, + RxBt = 0.05, -- +/- 0.05V + Curr = 2.0, -- +/- 2A + GSpd = 3.0, + Alt = 5, +} + +-- Upper bounds the jitter may not cross, for sensors whose range is fixed by +-- what they measure rather than by the scenario. +local sensorCeiling = { + RQly = 100, + TQly = 100, +} + +-- 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 + -- A link quality is a percentage of packets received, so it cannot + -- exceed 100. Jittering a base of 99 was handing the widgets 101, which + -- is not a reading any receiver can produce. + local ceiling = sensorCeiling[sensorId] + if ceiling and val > ceiling then + val = ceiling + 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/CRSFSimulator/shim.lua b/src/SCRIPTS/CRSFSimulator/shim.lua new file mode 100644 index 0000000..c0606fd --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/shim.lua @@ -0,0 +1,78 @@ +--------------------------------------------------------------------------- +-- Simulator Compatibility Layer -- +-- -- +-- Polyfills and byte-array helpers for the CRSF simulator mock. The -- +-- simulator also runs on B&W builds, where table.concat, table.remove -- +-- and table.unpack are missing. -- +-- -- +-- Usage: local shim = loadScript("/SCRIPTS/CRSFSimulator/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/ELRS/crsf.lua b/src/SCRIPTS/ELRS/crsf.lua new file mode 100644 index 0000000..62f5839 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -0,0 +1,303 @@ +--------------------------------------------------------------------------- +-- CRSF Protocol Singleton -- +-- -- +-- Shared CRSF transport: protocol constants, pop/push wrappers (and -- +-- their simulator mock seam) and link-layer decoders. Frames are -- +-- consumed pull-style: every consumer drains its own script instance's -- +-- queue through CRSF.drain() (the firmware replicates incoming frames -- +-- into each widget instance's private queue on color radios; on B&W the -- +-- standalone tool is the only consumer). Derived link state -- +-- (hasTelemetry) refreshes as a drain empties the queue. -- +-- -- +-- Loaded once via loadScript() from /SCRIPTS/ELRS/crsf.lua. -- +--------------------------------------------------------------------------- + +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() +local sensors = loadScript("/SCRIPTS/ELRS/sensors.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, + FRAMETYPE_COMMAND = 0x32, + FRAMETYPE_MSP_REQ = 0x7A, + FRAMETYPE_MSP_RESP = 0x7B, + FRAMETYPE_MSP_WRITE = 0x7C, + + -- COMMAND (0x32) subcommands (crsf_command_e / crsf_subcommand_e) + COMMAND_SUBCMD_RX = 0x10, + COMMAND_SUBCMD_RX_BIND = 0x01, + + -- 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 +-- ============================================================================ + +-- Link state: derived from RQly as a drain empties the queue +CRSF.hasTelemetry = false + +-- ============================================================================ +-- Default telemetry wrappers: delegate to real EdgeTX functions +-- When mocking is active, setMock() replaces the underlying implementations +-- ============================================================================ + +--- Pop one frame from the calling script instance's queue. +-- consumer identifies the caller to the simulator mock, which emulates the +-- firmware's per-widget queue replication with per-consumer cursors; real +-- hardware ignores it. An empty-queue return is the end of a drain, so the +-- derived link state refreshes here: frames are always ingested before +-- consumers can observe a hasTelemetry flip. The ELRS TX zeroes RQly on +-- disconnect, so a present, positive value is the truth about the link. +function CRSF.pop(consumer) + local command, data = CRSF._popImpl(consumer) + if command == nil then + CRSF.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 + end + return command, data +end + +function CRSF._popImpl(_consumer) + return crossfireTelemetryPop() +end + +--- Drain the calling script instance's queue, routing every frame through +-- onFrame(consumer, command, data). consumer is both the queue identity +-- handed to pop() and the receiver onFrame is invoked on, so consumers +-- pass their routing method directly: CRSF.drain(self, self._onFrame). +function CRSF.drain(consumer, onFrame) + local command, data + repeat + command, data = CRSF.pop(consumer) + if command then + onFrame(consumer, command, data) + end + until command == nil +end + +function CRSF.push(command, data) + return crossfireTelemetryPush(command, data) +end + +-- Read a telemetry sensor value by name (/SCRIPTS/ELRS/sensors.lua) +CRSF.getSensorValue = sensors.getSensorValue + +-- Drop the cached sensor IDs, which belong to the model that was loaded when +-- they were resolved. Whoever observes the model-change edge calls this; the +-- simulator mock reads by name and has no cache, so it needs no counterpart. +CRSF.resetSensorCache = sensors.resetCache + +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 + +-- ============================================================================ +-- 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._popImpl = mock.pop + CRSF.push = mock.push + CRSF.getSensorValue = mock.getSensorValue + CRSF.hasCrsfModule = function() + return mock.moduleFound + end +end + +setMock() +---@diagnostic disable-next-line: cast-local-type +setMock = nil + +-- ============================================================================ +-- Shared helpers +-- ============================================================================ + +--- Read a null-terminated string from a CRSF data array, converting the +-- bytes to chars in place and concatenating the slice. The frame table is +-- freshly allocated by every pop, and each frame type has exactly one +-- string-decoding consumer (DEVICE_INFO and ELRS_STATUS decode only here), +-- so mutating it is safe and saves a parts table per string. A second +-- decode of the same frame would raise (string.char on a string) rather +-- than corrupt silently. +-- @param data array of byte values +-- @param off 1-based start offset +-- @return string, nextOffset +local function readString(data, off) + local startOff = off + local b = data[off] + while b and b ~= 0 do + data[off] = string.char(b) + off = off + 1 + b = data[off] + end + return shim.tableConcat(data, nil, startOff, off - 1), 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 + +--- Send a COMMAND bind request. Addressed to the TX module it enters bind +-- mode, transmitting to any RX waiting in bind mode; addressed to the RX it +-- unbinds a connected receiver. +-- @param dest CRSF device address (use CRSF.CONST.ADDRESS_*); nil targets the TX +function CRSF:sendBindCommand(dest) + CRSF.push(CRSF.CONST.FRAMETYPE_COMMAND, { + dest or CRSF.CONST.ADDRESS_TX, + CRSF.CONST.ADDRESS_HANDSET, + CRSF.CONST.COMMAND_SUBCMD_RX, + CRSF.CONST.COMMAND_SUBCMD_RX_BIND, + }) +end + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return CRSF diff --git a/src/SCRIPTS/ELRS/crsf_params.lua b/src/SCRIPTS/ELRS/crsf_params.lua new file mode 100644 index 0000000..8006b0e --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_params.lua @@ -0,0 +1,478 @@ +--------------------------------------------------------------------------- +-- 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. The -- +-- single-frame fast path in reassemble() hands the caller's frame back -- +-- as the decode buffer, and decodeEntry may scan it more than once -- +-- (cached-name skip), so decoding must stay read-only. -- +-- -- +-- 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 from a byte array without mutating it. +-- Names, units and info strings never carry the legacy ELRS arrow bytes +-- (the firmware emits them only inside selection options), so no glyph +-- translation happens here. +-- @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. +-- @return string result (or last as-is), number nextOffset +function Params.readString(data, offset, last) + if last then + local b = data[offset] + while b and b ~= 0 do + offset = offset + 1 + b = data[offset] + end + return last, offset + 1 + end + local parts = {} + local b = data[offset] + while b and b ~= 0 do + parts[#parts + 1] = string.char(b) + offset = offset + 1 + b = data[offset] + end + return shim.tableConcat(parts), offset + 1 +end + +--- Read a null-terminated, semicolon-separated option list into the +-- caller-owned values table, refilling it in place so its identity is +-- stable for the field's lifetime. Empty options stay as "" entries (the +-- UI disables those slots); leftover slots from a previously longer list +-- are truncated. 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 values the caller-owned option table to refill +-- @return number nextOffset, number optCount (count of non-empty options), +-- boolean changed (any slot differs from the previous contents) +function Params.readOptions(data, offset, values) + local n = 0 + local vcnt = 0 + local changed = false + local optParts = {} + local b = data[offset] + while b do + offset = offset + 1 + if b == 59 or b == 0 then + local opt = shim.tableConcat(optParts) + n = n + 1 + if values[n] ~= opt then + values[n] = opt + changed = true + end + if #optParts > 0 then + vcnt = vcnt + 1 + optParts = {} + end + if b == 0 then + break + end + elseif 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 + b = data[offset] + end + for i = #values, n + 1, -1 do + values[i] = nil + changed = true + end + return offset, vcnt, changed +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.readString(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 cached = field.dirty == nil and field.values or nil + if cached then + -- Options already decoded and not flagged dirty: skip the blob + cached, offset = Params.readString(data, offset, cached) + else + local values = field.values + if values == nil then + values = {} + field.values = values + end + local vcnt, changed + offset, vcnt, changed = Params.readOptions(data, offset, values) + field.disabled = (vcnt <= 1) or nil + if changed then + -- Consumers watch this revision instead of table identity: the values + -- table is refilled in place and keeps its identity for the field's + -- lifetime. + field.valuesRev = (field.valuesRev or 0) + 1 + end + end + field.value = data[offset] + local unit = Params.readString(data, offset + 4) + field.unit = (unit ~= "") and unit or nil + field.dirty = nil +end + +local function fieldStringLoad(field, data, offset) + field.value, offset = Params.readString(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.readString(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 passive +-- caller (acceptUnsolicited) 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 +-- readString); 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.readString(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..a422e0d --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -0,0 +1,711 @@ +--------------------------------------------------------------------------- +-- 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. -- +-- -- +-- Frames arrive through drain(): every consumer pops its own script -- +-- instance's queue (the firmware replicates incoming frames per widget -- +-- instance on color radios) and _onFrame routes them internally. -- +-- -- +-- 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: the one receive path +-- for every consumer. The queue popped is the calling script instance's +-- own, so a session drains exactly the frames delivered to its owner. +function CRSFSession:drain() + crsf.drain(self, self._onFrame) +end + +--- Ask every reachable device to announce itself (broadcast DEVICE_PING). +-- Answers land in .devices through the discovery routing, so this is the +-- policy-facing "refresh the device list now" -- the scheduler's own +-- cadence only pings while the list is still empty. Meaningful only with +-- opts.discovery. +function CRSFSession:discoverDevices() + crsf:pingDevices() +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 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 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/defer.lua b/src/SCRIPTS/ELRS/defer.lua new file mode 100644 index 0000000..508f7cd --- /dev/null +++ b/src/SCRIPTS/ELRS/defer.lua @@ -0,0 +1,52 @@ +--------------------------------------------------------------------------- +-- Deferred Callback Timer -- +-- Loaded via loadScript() with no arguments; returns the Defer table. -- +-- -- +-- A single-slot setTimeout: at most one callback is pending at a time, -- +-- and scheduling a new one replaces it. That replacement is the point, -- +-- not a limitation -- a consumer sequencing wire traffic (send, retry, -- +-- follow-up) wants a new action to cancel whatever was pending. A -- +-- consumer needing independent timers needs a different tool. -- +-- -- +-- Each loadScript() execution returns a fresh table, so every consumer -- +-- owns a private slot. poll() must be called once per run() tick. -- +--------------------------------------------------------------------------- + +local Defer = { + _cb = nil, +} + +--- Schedule fn(ctx) to run once no sooner than ticks from now, replacing +-- any pending callback. +-- @param ticks delay in getTime() units (10 ms) +-- @param fn callback +-- @param ctx passed to fn; nil is fine +function Defer.setTimeout(ticks, fn, ctx) + Defer._cb = { + start = getTime(), + ticks = ticks, + fn = fn, + ctx = ctx, + } +end + +--- Drop the pending callback, if any. +function Defer.clear() + Defer._cb = nil +end + +--- Run the pending callback when its delay has elapsed. The slot is +-- cleared before the call so the callback may schedule a successor. +function Defer.poll() + local cb = Defer._cb + if cb == nil then + return + end + if getTime() - cb.start < cb.ticks then + return + end + Defer._cb = nil + cb.fn(cb.ctx) +end + +return Defer diff --git a/src/SCRIPTS/ELRS/edgetx_version.lua b/src/SCRIPTS/ELRS/edgetx_version.lua new file mode 100644 index 0000000..f90e0a9 --- /dev/null +++ b/src/SCRIPTS/ELRS/edgetx_version.lua @@ -0,0 +1,28 @@ +--------------------------------------------------------------------------- +-- EdgeTX Version Gate -- +-- Loaded via loadScript() with no arguments; returns the isSupported -- +-- function. -- +-- -- +-- The one home of the package's minimum EdgeTX requirement. The tools -- +-- check it once at load and hand the result to their UI chunk, whose -- +-- preCheck owns the presentation. Keep the manifest's -- +-- min_edgetx_version (edgetx.yml) in step with this ladder. -- +--------------------------------------------------------------------------- + +--- True when the running firmware meets the minimum: 2.11.6, 2.12.1 +-- or 3.0. +local function isSupported() + 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 + +return isSupported diff --git a/src/SCRIPTS/ELRS/file_storage.lua b/src/SCRIPTS/ELRS/file_storage.lua new file mode 100644 index 0000000..c3d94e0 --- /dev/null +++ b/src/SCRIPTS/ELRS/file_storage.lua @@ -0,0 +1,82 @@ +--------------------------------------------------------------------------- +-- 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. -- +--------------------------------------------------------------------------- + +-- B&W EdgeTX ships without the table library; the shim routes around it. +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + +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] = shim.tableConcat({ keys[i], "=", val, "\n" }) + end + end + io.write(f, shim.tableConcat(lines)) + io.close(f) + return true +end + +return FileStorage diff --git a/src/SCRIPTS/ELRS/loader.lua b/src/SCRIPTS/ELRS/loader.lua new file mode 100644 index 0000000..b4c7cad --- /dev/null +++ b/src/SCRIPTS/ELRS/loader.lua @@ -0,0 +1,27 @@ +--------------------------------------------------------------------------- +-- GC-Guarded Script Loader -- +-- Loaded via loadScript() with no arguments; returns the loader -- +-- function. The one library part that cannot load itself: consumers -- +-- bootstrap it with a bare loadScript call. -- +-- -- +-- A full collection before each load frees the previous compile's -- +-- parser scratch; the firmware runs no GC between loadScript calls, so -- +-- on a fresh install (no .luac yet) the compile peaks would otherwise -- +-- stack -- the cause of the B&W out-of-memory on a tool's first launch. -- +--------------------------------------------------------------------------- + +--- Load a script chunk and run it, passing the remaining arguments +-- through. Raises with the path when the script is missing or fails to +-- compile, which names the culprit in the firmware's error screen. +-- @param path absolute SD path +-- @return the chunk's return value +local function loader(path, ...) + collectgarbage("collect") + local chunk = loadScript(path) + if chunk == nil then + error(path) + end + return chunk(...) +end + +return loader diff --git a/src/SCRIPTS/ELRS/msp.lua b/src/SCRIPTS/ELRS/msp.lua new file mode 100644 index 0000000..24badc9 --- /dev/null +++ b/src/SCRIPTS/ELRS/msp.lua @@ -0,0 +1,170 @@ +--------------------------------------------------------------------------- +-- MSP-over-CRSF Codec -- +-- Loaded via loadScript() with (crsf); returns the Msp table. -- +-- -- +-- Stateless encoders and decoders for MSP frames tunnelled in CRSF -- +-- (frame types MSP_REQ/MSP_RESP/MSP_WRITE). Encoders return -- +-- (frameType, payload) for the caller to crsf.push(); decoders never -- +-- mutate the frame. Single-frame v1 traffic only: every message this -- +-- repo speaks (ELRS RXTX_CONFIG) fits one CRSF frame, so the chunked -- +-- transfer protocol (sequence numbers, reassembly, XOR CRC) is not -- +-- implemented. The firmware verifies no MSP CRC on these frames -- +-- (RxTxEndpoint.cpp reads the payload by offset), so encoders append -- +-- none, and the decoder ignores anything past the declared size. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local Msp = {} + +-- ============================================================================ +-- Named protocol constants +-- ============================================================================ + +Msp.CONST = { + -- MSP-over-CRSF status byte: bits 0-3 sequence, bit 4 start-of-frame, + -- bits 5-6 MSP version, bit 7 error (responses only) + MSP_STARTFLAG = 0x10, + MSP_VERSION_V1 = 0x20, + MSP_ERRORFLAG = 0x80, + + -- MSP function ids (ExpressLRS msptypes.h; RXTX_CONFIG needs ELRS 4.1+) + MSP_ELRS_RXTX_CONFIG = 0x2D, + + -- MSP_ELRS_RXTX_CONFIG subcommands (first payload byte) + RXTX_UID = 0x00, + RXTX_BIND_PHRASE = 0x01, + RXTX_MODEL_ID = 0x0A, + + -- A bind phrase must fit a single un-chunked MSP_WRITE frame + PHRASE_MAX = 52, +} + +-- Header of every outgoing frame: version 1, start-of-frame, sequence 0. +-- Single-frame messages never advance the sequence. +local HEADER = Msp.CONST.MSP_VERSION_V1 + Msp.CONST.MSP_STARTFLAG + +-- Version bits mask within the status byte +local VERSION_MASK = 0x60 + +--- Build the shared MSP payload layout: extended-frame addressing, header, +-- size (bytes after fn: subcommand plus data), function id, then args. +local function encode(deviceId, handsetId, fn, args) + local payload = { deviceId, handsetId, HEADER, #args, fn } + for i = 1, #args do + payload[5 + i] = args[i] + end + return payload +end + +-- ============================================================================ +-- Encoders: return (frameType, payload) for crsf.push() +-- ============================================================================ + +--- Encode an MSP read request. +-- @param deviceId destination CRSF address +-- @param handsetId source CRSF address (the handset) +-- @param fn MSP function id +-- @param args array of payload bytes (subcommand first) +-- @return frameType, payload +function Msp.encodeRead(deviceId, handsetId, fn, args) + return crsf.CONST.FRAMETYPE_MSP_REQ, encode(deviceId, handsetId, fn, args) +end + +--- Encode an MSP write. +-- @param deviceId destination CRSF address +-- @param handsetId source CRSF address (the handset) +-- @param fn MSP function id +-- @param args array of payload bytes (subcommand first) +-- @return frameType, payload +function Msp.encodeWrite(deviceId, handsetId, fn, args) + return crsf.CONST.FRAMETYPE_MSP_WRITE, encode(deviceId, handsetId, fn, args) +end + +--- Encode a request for the device's current bind UID. +-- @return frameType, payload +function Msp.encodeUidRead(deviceId, handsetId) + return Msp.encodeRead(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, { Msp.CONST.RXTX_UID }) +end + +--- Encode a write of a raw 6-byte bind UID. +-- @param uid array of six byte values +-- @return frameType, payload +function Msp.encodeUidWrite(deviceId, handsetId, uid) + return Msp.encodeWrite(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, { + Msp.CONST.RXTX_UID, + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + }) +end + +--- Encode a write of a bind phrase; the device derives its UID from it. +-- @param phrase string of at most PHRASE_MAX chars +-- @return frameType, payload +function Msp.encodePhraseWrite(deviceId, handsetId, phrase) + local args = { Msp.CONST.RXTX_BIND_PHRASE } + for i = 1, #phrase do + args[i + 1] = string.byte(phrase, i) + end + return Msp.encodeWrite(deviceId, handsetId, Msp.CONST.MSP_ELRS_RXTX_CONFIG, args) +end + +-- ============================================================================ +-- Decoders +-- ============================================================================ + +--- Decode an MSP_RESP frame addressed to the handset. Accepts only v1 +-- single-frame responses (start flag set); continuation chunks and other +-- MSP versions return nil. Source gating stays with the caller, matching +-- decodeDeviceInfo's convention. +-- @param data array of byte values +-- @return srcId, fn, offset, len -- payload is data[offset .. offset+len-1] +-- (subcommand first), or nil when the frame is not a complete +-- single-frame v1 response to the handset +function Msp.decodeResponse(data) + if data[1] ~= crsf.CONST.ADDRESS_HANDSET then + return nil + end + local status = data[3] + local size = data[4] + if status == nil or size == nil then + return nil + end + if not bit32.btest(status, Msp.CONST.MSP_STARTFLAG) then + return nil + end + if bit32.band(status, VERSION_MASK) ~= Msp.CONST.MSP_VERSION_V1 then + return nil + end + local fn = data[5] + if fn == nil or data[5 + size] == nil then + return nil + end + return data[2], fn, 6, size +end + +--- Decode an RXTX_CONFIG/UID answer into the caller-owned out[1..6] +-- (reused across frames: no per-frame table). +-- @param data array of byte values +-- @param out table receiving the six UID bytes +-- @return srcId (source CRSF address) or nil when the frame is not a +-- complete UID response +function Msp.decodeUid(data, out) + local srcId, fn, offset, len = Msp.decodeResponse(data) + if srcId == nil or fn ~= Msp.CONST.MSP_ELRS_RXTX_CONFIG then + return nil + end + if data[offset] ~= Msp.CONST.RXTX_UID or len < 7 then + return nil + end + for i = 1, 6 do + out[i] = data[offset + i] + end + return srcId +end + +return Msp diff --git a/src/SCRIPTS/ELRS/sensors.lua b/src/SCRIPTS/ELRS/sensors.lua new file mode 100644 index 0000000..93f8f50 --- /dev/null +++ b/src/SCRIPTS/ELRS/sensors.lua @@ -0,0 +1,47 @@ +--------------------------------------------------------------------------- +-- Telemetry Sensor Reader -- +-- -- +-- Generic EdgeTX telemetry access: read a sensor value by name, with a -- +-- cached getFieldInfo string->ID lookup. Nothing here is CRSF-specific. -- +-- Loaded by /SCRIPTS/ELRS/crsf.lua, which exposes the reader to every -- +-- consumer as crsf.getSensorValue (and swaps in the simulator mock's -- +-- reader when active). -- +-- -- +-- The cache is keyed by name but the IDs in it belong to one model, so -- +-- whoever owns the model-change edge must call resetCache(). -- +--------------------------------------------------------------------------- + +local Sensors = {} + +-- Field ID cache (string sensor name -> numeric ID) +Sensors._vCache = {} + +--- Forget every cached ID. Call on a model change. +-- A telemetry field's ID encodes a slot in the *current* model's sensor list +-- (MIXSRC_FIRST_TELEM + 3 * index, resolved against g_model.telemetrySensors), +-- so an ID cached under one model addresses a different sensor under the next. +-- The cache lives in a singleton that outlives model changes, so nothing +-- invalidates it on its own. +function Sensors.resetCache() + Sensors._vCache = {} +end + +--- 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. +function Sensors.getSensorValue(id) + local cid = Sensors._vCache[id] + if cid == nil then + local info = getFieldInfo(id) + if info == nil then + return nil + end + cid = info.id + Sensors._vCache[id] = cid + end + return getValue(cid) +end + +return Sensors diff --git a/src/SCRIPTS/ELRS/shim.lua b/src/SCRIPTS/ELRS/shim.lua new file mode 100644 index 0000000..e7f05ba --- /dev/null +++ b/src/SCRIPTS/ELRS/shim.lua @@ -0,0 +1,39 @@ +--------------------------------------------------------------------------- +-- B&W Compatibility Layer -- +-- -- +-- Polyfill for table.concat, which is missing on B&W radios. -- +-- -- +-- 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 + +return shim diff --git a/src/SCRIPTS/ELRS/ui/lcd/alert.lua b/src/SCRIPTS/ELRS/ui/lcd/alert.lua new file mode 100644 index 0000000..d7cd418 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/alert.lua @@ -0,0 +1,37 @@ +--------------------------------------------------------------------------- +-- B&W Alert Screen -- +-- Loaded via loadScript() with no arguments; returns the drawAlert -- +-- function. Shared by every tool's B&W UI. -- +-- -- +-- Full-screen alert: MIDSIZE title, body lines, and an optional row of -- +-- action labels along the bottom. -- +--------------------------------------------------------------------------- + +-- B&W text row height +local TEXT_H = 8 + +--- Clear the screen and draw the alert. +-- @param title heading, drawn MIDSIZE +-- @param msgs array of body lines +-- @param actions optional { left, right } action labels for the bottom row +local function drawAlert(title, msgs, actions) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (TEXT_H * 2) - 2 + for _, msg in ipairs(msgs) do + lcd.drawText(2, y, msg) + y = y + TEXT_H + end + if actions then + y = y + TEXT_H + 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 + +return drawAlert diff --git a/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua b/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua new file mode 100644 index 0000000..444c8f1 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua @@ -0,0 +1,182 @@ +--------------------------------------------------------------------------- +-- B&W Text Editor -- +-- Loaded via loadScript() with no arguments; returns the TextEdit -- +-- table. Construct one instance per editable field: TextEdit.new(). -- +-- -- +-- A Lua port of the firmware's editName() (gui/common/stdlcd/ -- +-- draw_functions.cpp:178), so editing a string in a tool feels exactly -- +-- like editing a model name: the cursor starts on the first char, -- +-- rotary cycles that char through the name charset (clamped at the -- +-- ends, case preserved), ENTER advances the cursor, ENTER on the last -- +-- cell or long ENTER on a space commits, and long ENTER on a letter -- +-- toggles its case. EXIT also commits -- edits are applied to the -- +-- value as they are made, never reverted. Trailing spaces are stripped -- +-- on commit; deletion is overwriting with spaces. -- +--------------------------------------------------------------------------- + +local TextEdit = {} +TextEdit.__index = TextEdit + +-- The firmware's nameChars. Digits and the comma are in it, so a raw UID +-- ("0,1,2,3,4,5") can be entered with the same editor. +local CHARS = " abcdefghijklmnopqrstuvwxyz0123456789_-,." + +--- Index of a char in CHARS, upper-case letters mapping like their +-- lower-case form (nameCharIdx in the firmware). Unknown chars map to +-- the leading space. +local function charIdx(c) + local b = string.byte(c) + if b >= 65 and b <= 90 then + c = string.char(b + 32) + end + local idx = string.find(CHARS, c, 1, true) + -- "." is no pattern here thanks to the plain flag; the charset has no + -- other metachars. + return idx or 1 +end + +local function isUpper(c) + local b = string.byte(c) + return b ~= nil and b >= 65 and b <= 90 +end + +local function isLower(c) + local b = string.byte(c) + return b ~= nil and b >= 97 and b <= 122 +end + +--- Construct an editor instance. +-- @param maxLen maximum value length in chars +-- @param visible chars that fit the row; longer values draw a window +-- ending at the cursor +function TextEdit.new(maxLen, visible) + return setmetatable({ + maxLen = maxLen, + visible = visible, + value = "", + editing = nil, + cur = 1, + }, TextEdit) +end + +--- Enter edit mode with the cursor on the first char. +function TextEdit:start() + self.editing = true + self.cur = 1 +end + +--- Replace the char under the cursor, padding with spaces when the +-- cursor sits past the end of the value. +function TextEdit:_setChar(c) + local v = self.value + while #v < self.cur - 1 do + v = v .. " " + end + self.value = string.sub(v, 1, self.cur - 1) .. c .. string.sub(v, self.cur + 1) +end + +function TextEdit:_charAt(pos) + local c = string.sub(self.value, pos, pos) + if c == "" then + return " " + end + return c +end + +function TextEdit:_commit() + self.editing = nil + local v = self.value + local last = #v + while last > 0 and string.byte(v, last) == 32 do + last = last - 1 + end + self.value = string.sub(v, 1, last) +end + +--- Handle one event while editing. Returns true when the edit committed +-- on this event, nil while it continues. +function TextEdit:handleEvent(event) + local c = self:_charAt(self.cur) + + if event == EVT_VIRTUAL_NEXT or event == EVT_VIRTUAL_PREV then + local idx = charIdx(c) + if event == EVT_VIRTUAL_NEXT then + idx = math.min(idx + 1, #CHARS) + else + idx = math.max(idx - 1, 1) + end + local nc = string.sub(CHARS, idx, idx) + if isUpper(c) and isLower(nc) then + nc = string.char(string.byte(nc) - 32) + end + self:_setChar(nc) + return nil + end + + if event == EVT_VIRTUAL_ENTER then + if self.cur < self.maxLen then + self.cur = self.cur + 1 + else + self:_commit() + return true + end + return nil + end + + if event == EVT_VIRTUAL_ENTER_LONG then + killEvents(event) + if c == " " then + self:_commit() + return true + elseif isUpper(c) then + self:_setChar(string.char(string.byte(c) + 32)) + elseif isLower(c) then + self:_setChar(string.char(string.byte(c) - 32)) + end + return nil + end + + if event == EVT_VIRTUAL_EXIT then + self:_commit() + return true + end + + return nil +end + +--- Draw the value at (x, y). attr is the row's base attribute (INVERS on +-- the selected row); while editing, only the cursor cell is inverted, +-- like the firmware's edit rendering. +function TextEdit:draw(x, y, attr) + if not self.editing then + local shown = self.value + if shown == "" then + shown = "---" + elseif self.visible and #shown > self.visible then + shown = string.sub(shown, 1, self.visible) + end + lcd.drawText(x, y, shown, attr) + return + end + + -- Window the value so the cursor is always on screen + local first = 1 + if self.visible and self.cur > self.visible then + first = self.cur - self.visible + 1 + end + local prefix = string.sub(self.value, first, self.cur - 1) + local suffix = "" + if self.visible then + suffix = string.sub(self.value, self.cur + 1, first + self.visible - 1) + else + suffix = string.sub(self.value, self.cur + 1) + end + + lcd.drawText(x, y, prefix) + lcd.drawText(lcd.getLastPos(), y, self:_charAt(self.cur), INVERS) + if suffix ~= "" then + lcd.drawText(lcd.getLastPos(), y, suffix) + end +end + +return TextEdit diff --git a/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua b/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua new file mode 100644 index 0000000..b05d031 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua @@ -0,0 +1,81 @@ +--------------------------------------------------------------------------- +-- Color LCD Startup Dialogs -- +-- Loaded via loadScript() with no arguments; returns the Dialogs table. -- +-- Shared by every tool's LVGL UI. -- +-- -- +-- The two dialogs a tool can raise before it has a page: the EdgeTX -- +-- version gate and the missing-module notice. Both are terminal -- the -- +-- only way out is exiting the tool -- so each takes the caller's -- +-- onExit and wires it to both the dialog's close box and its Exit -- +-- button. -- +--------------------------------------------------------------------------- + +local Dialogs = {} + +--- Build a full-screen dialog: a column of text lines over a single Exit +-- button. lines are label descriptors ({ text = ..., font = ... }), used +-- verbatim as children. +local function buildExitDialog(title, lines, onExit) + lvgl.clear() + + local dg = lvgl.dialog({ + title = title, + 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 = lines, + }, + { + 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 + +--- The EdgeTX version gate. Keep the versions in step with the ladder in +-- SCRIPTS/ELRS/edgetx_version.lua, which decides when this is shown. +function Dialogs.showVersionRequired(onExit) + return buildExitDialog("EdgeTX Version Not Supported", { + { type = lvgl.LABEL, text = "Requires EdgeTX:" }, + { type = lvgl.LABEL, text = "- 2.11.6 or later" }, + { type = lvgl.LABEL, text = "- 2.12.1 or later" }, + { type = lvgl.LABEL, text = "- 3.0 or later" }, + }, onExit) +end + +--- No CRSF module configured on the model. +function Dialogs.showNoModule(onExit) + return buildExitDialog("No Module Found: Check Model Settings", { + { 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" }, + }, onExit) +end + +return Dialogs diff --git a/src/SCRIPTS/TOOLS/ExpressLRS/main.lua b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua new file mode 100644 index 0000000..2e6cd19 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -0,0 +1,214 @@ +-- 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 +-- ============================================================================ + +-- The loader is the one shared part that must bootstrap with a bare +-- loadScript; it owns the GC-before-load discipline. +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() + +local crsf = loader("/SCRIPTS/ELRS/crsf.lua") +local params = loader("/SCRIPTS/ELRS/crsf_params.lua", crsf) +local CRSFSession = loader("/SCRIPTS/ELRS/crsf_session.lua", crsf, params) +local Navigation = loader("/SCRIPTS/TOOLS/ExpressLRS/navigation.lua") +local versionOk = loader("/SCRIPTS/ELRS/edgetx_version.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, then +-- re-discover so new devices appear. +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 + session:discoverDevices() +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, + versionOk = versionOk, + } + if useLvgl then + UI = loader("/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua", deps) + else + UI = loader("/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..bd749a0 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -0,0 +1,78 @@ +---- ######################################################################### +---- # 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 entry = { + type = Navigation.TYPE_FOLDER, + id = folderId, + name = folderName, + } + 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..cbc0603 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -0,0 +1,597 @@ +---- ######################################################################### +---- # 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 drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() + +-- ============================================================================ +-- UI state +-- ============================================================================ + +-- Title warning flash half-period, in 10 ms ticks. Also paces the idle page +-- repaint (the flash tick sets forceRedraw), so halving it doubles that rate. +local WARN_FLASH_PERIOD = 100 + +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 = 0, + titleWarnFlags = nil, -- last flags byte the flash phase was anchored to + + -- 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 +end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(event) + if not deps.versionOk then + 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() + 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() + drawAlert("Unsupported Firmware", { + "ELRS 1.x firmware detected.", + "Please update to 3.x.", + }) +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, _touchState) + -- Warning flash: any flags change re-anchors the phase, so a new warning + -- starts on its visible half and a cleared one disappears at once + local time = getTime() + local flags = session.status.flags + if flags ~= UI.titleWarnFlags then + UI.titleWarnFlags = flags + UI.titleShowWarn = (flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK) or nil + UI.titleShowWarnTimeout = time + WARN_FLASH_PERIOD + UI.forceRedraw = true + elseif time > UI.titleShowWarnTimeout then + UI.titleShowWarn = (flags > crsf.CONST.ELRS_FLAGS_STATUS_MASK and not UI.titleShowWarn) or nil + UI.titleShowWarnTimeout = time + WARN_FLASH_PERIOD + 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 + 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 + +-- ============================================================================ +-- 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..ccbb7d0 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -0,0 +1,1011 @@ +---- ######################################################################### +---- # 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 + +local SharedDialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + +-- ============================================================================ +-- 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 + +-- ============================================================================ +-- Startup dialogs (version gate, no module): SCRIPTS/ELRS/ui/lvgl/dialogs.lua +-- ============================================================================ + +local function exitTool() + App.shouldExit = true +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 + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(_event) + if not deps.versionOk then + if not UI.uiBuilt then + SharedDialogs.showVersionRequired(exitTool) + 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 + SharedDialogs.showNoModule(exitTool) + 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) + -- The subtitle shows the folder name without the dynamic value suffix + -- ExpressLRS embeds in it (e.g. "VTX Admin (R:4:2:P)"). + if folderName then + local par = string.find(folderName, " (", 1, true) + if par then + folderName = string.sub(folderName, 1, par - 1) + end + end + 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.valuesRev + local choiceWidget + + local setting = pg:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = field.name, + visible = function() + if field.hidden then + return false + end + -- The values table is refilled in place (identity is stable); the + -- codec bumps valuesRev when the contents change. + if field.valuesRev ~= valuesRef then + valuesRef = field.valuesRev + 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/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua new file mode 100644 index 0000000..9f7cd41 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua @@ -0,0 +1,93 @@ +--------------------------------------------------------------------------- +-- Bind Phrase History Storage -- +-- Loaded via loadScript() from ExpressLRSBind/main.lua with -- +-- (FileStorage); returns the History table. -- +-- -- +-- The last MAX phrases, newest first, persisted as indexed keys h1..hN -- +-- (the ELRSVTXAdmin presets c1..c6 idiom). Worst case -- +-- MAX * (3 + 52 + 1) = 280 bytes, inside FileStorage's bounded 512-byte -- +-- read; raising MAX or the phrase length must revisit that budget or -- +-- the tail entries truncate silently. No table.insert/remove: B&W -- +-- radios ship without the table library, so shifts are plain loops. -- +--------------------------------------------------------------------------- + +local FileStorage = ... + +-- Where the history lives on the SD card +local PATH = "/SCRIPTS/TOOLS/ExpressLRSBind/history.txt" + +local MAX = 5 + +-- The file layout, declared once: FileStorage writes these keys in this order. +local SAVE_KEYS = {} +for i = 1, MAX do + SAVE_KEYS[i] = "h" .. i +end + +local History = { + MAX = MAX, + -- items[1] is the most recent phrase + items = {}, +} + +local function save() + local values = {} + for i = 1, #History.items do + values[SAVE_KEYS[i]] = History.items[i] + end + FileStorage.write(PATH, SAVE_KEYS, values) +end + +--- Put phrase at the front, dropping an existing copy (a re-used phrase +-- moves up instead of duplicating) and trimming past MAX. +function History.add(phrase) + if phrase == nil or phrase == "" then + return + end + local items = History.items + for i = #items, 1, -1 do + if items[i] == phrase then + for j = i, #items - 1 do + items[j] = items[j + 1] + end + items[#items] = nil + end + end + for j = math.min(#items + 1, MAX), 2, -1 do + items[j] = items[j - 1] + end + items[1] = phrase + save() +end + +function History.remove(idx) + local items = History.items + if items[idx] == nil then + return + end + for j = idx, #items - 1 do + items[j] = items[j + 1] + end + items[#items] = nil + save() +end + +function History.clear() + History.items = {} + save() +end + +-- Initialize from file. h1..hN are read in order; the first missing key +-- ends the list, so a hand-edited file with gaps loads its head only. +local kv = FileStorage.read(PATH) +if kv then + for i = 1, MAX do + local v = kv[SAVE_KEYS[i]] + if v == nil then + break + end + History.items[i] = v + end +end + +return History diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua new file mode 100644 index 0000000..8c5f806 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -0,0 +1,344 @@ +-- TNS|ExpressLRS Bind|TNE +---- ######################################################################### +---- # # +---- # ExpressLRS bind phrase manager for BW and color LCD radios # +---- # (EdgeTX 2.11.6+/2.12.1+). Reads and writes the bind phrase / UID # +---- # over MSP; the device side requires ExpressLRS 4.1+. # +---- # # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- ######################################################################### + +local VERSION = "r1" +local useLvgl = (lvgl ~= nil) + +-- ============================================================================ +-- Load shared modules +-- ============================================================================ + +-- The loader is the one shared part that must bootstrap with a bare +-- loadScript; it owns the GC-before-load discipline. +---@diagnostic disable-next-line: need-check-nil +local loader = loadScript("/SCRIPTS/ELRS/loader.lua")() + +local crsf = loader("/SCRIPTS/ELRS/crsf.lua") +local msp = loader("/SCRIPTS/ELRS/msp.lua", crsf) +local defer = loader("/SCRIPTS/ELRS/defer.lua") +local FileStorage = loader("/SCRIPTS/ELRS/file_storage.lua") +local History = loader("/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua", FileStorage) +local versionOk = loader("/SCRIPTS/ELRS/edgetx_version.lua")() + +-- ============================================================================ +-- App Module: business logic shared by both UI frontends +-- ============================================================================ + +-- Scheduler constants (getTime() ticks of 10 ms) +local UID_RETRY_TICKS = 50 -- resend an unanswered UID read after 500 ms +local UID_MAX_ATTEMPTS = 6 -- then give up: the device has no MSP config support +local FOLLOWUP_TICKS = 100 -- settle time before a follow-up after a write + +local App = { + -- Target selector positions (order matches the UIs' choice lists) + TARGET_TX = 1, + TARGET_RX = 2, + TARGET_BOTH = 3, + + target = 1, + phrase = "", + bothStep = nil, -- TARGET_RX while the Both sequence's RX leg is in flight + uid = {}, -- reused 6-slot byte table, filled by msp.decodeUid + uidFrom = nil, -- source address of the last UID answer; nil until one lands + statusText = "Idle", -- transient status line; nil shows the UID bytes + uidAttempts = 0, + -- Bumped only when a build-time snapshot must refresh (the TEXT_EDIT + -- value, the CHOICE selection). Everything else on the LVGL page reads + -- live getters, and a rebuild resets rotary focus -- so status and UID + -- updates must NOT bump this. + rev = 0, + history = History, + + shouldExit = false, + crsfModuleChecked = false, + crsfModuleFound = false, +} + +function App.checkCrsfModule() + if App.crsfModuleChecked then + return App.crsfModuleFound + end + App.crsfModuleChecked = true + App.crsfModuleFound = crsf.hasCrsfModule() + return App.crsfModuleFound +end + +-- The TX module answers on the handset UART; the RX only over an active link. +function App.isTargetReachable() + return App.target == App.TARGET_TX or (App.target == App.TARGET_RX and crsf.hasTelemetry) +end + +function App.isTargetReachableOrBoth() + return App.target == App.TARGET_BOTH or App.isTargetReachable() +end + +--- Status line for the UIs: transient status while an exchange is in +-- flight, then the last UID answer. +function App.uidLine() + if App.statusText then + return App.statusText + end + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d, %d, %d, %d, %d, %d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) +end + +--- Parse one comma-separated segment as a plain decimal byte (surrounding +-- spaces allowed). No patterns: B&W-friendly byte walking. +local function parseByte(part) + local i = 1 + local j = #part + while i <= j and string.byte(part, i) == 32 do + i = i + 1 + end + while j >= i and string.byte(part, j) == 32 do + j = j - 1 + end + if i > j or j - i > 2 then + return nil + end + local n = 0 + for k = i, j do + local b = string.byte(part, k) + if b < 48 or b > 57 then + return nil + end + n = n * 10 + (b - 48) + end + if n > 255 then + return nil + end + return n +end + +--- Interpret the phrase text as a raw UID: 4-6 comma-separated bytes, +-- left-padded with zeros to 6. Returns nil when the text is a phrase. +function App.parseUidText(text) + local bytes = {} + local pos = 1 + while pos <= #text do + local comma = string.find(text, ",", pos, true) + local part + if comma then + part = string.sub(text, pos, comma - 1) + pos = comma + 1 + else + part = string.sub(text, pos) + pos = #text + 1 + end + local n = parseByte(part) + if n == nil then + return nil + end + bytes[#bytes + 1] = n + end + local count = #bytes + if count < 4 or count > 6 then + return nil + end + local uid = { 0, 0, 0, 0, 0, 0 } + for i = 1, count do + uid[6 - count + i] = bytes[i] + end + return uid +end + +--- Request the target's UID, retrying on silence. Bounded: an ELRS device +-- without MSP config support (pre-4.1) never answers, and the retry must +-- not spam the wire forever. +function App.requestUid() + if not App.isTargetReachable() then + App.statusText = "Idle" + return + end + if App.uidAttempts >= UID_MAX_ATTEMPTS then + App.statusText = "No response (needs ELRS 4.1+)" + return + end + App.uidAttempts = App.uidAttempts + 1 + App.statusText = "Updating..." + local dest = (App.target == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + crsf.push(msp.encodeUidRead(dest, crsf.CONST.ADDRESS_HANDSET)) + defer.setTimeout(UID_RETRY_TICKS, App.requestUid) +end + +--- User-facing entry point: start a fresh UID request cycle. +function App.startUidRequest() + App.uidAttempts = 0 + App.requestUid() +end + +--- Send the phrase (or raw UID) to the selected target. "Both" is a +-- two-step sequence: the RX first -- writing its phrase drops it off the +-- link -- then the TX, after which the selector rests on Transmitter. +function App.sendSet() + if App.phrase == "" then + return + end + if App.target == App.TARGET_BOTH then + if App.bothStep == nil then + App.bothStep = App.TARGET_RX + else + App.bothStep = nil + -- The selector visibly rests on Transmitter after the sequence; the + -- CHOICE renders its build-time selection, so this needs a rebuild. + App.target = App.TARGET_TX + App.rev = App.rev + 1 + end + end + local effective = App.bothStep or App.target + if App.bothStep == nil then + App.statusText = (App.target == App.TARGET_TX) and "Setting transmitter..." or "Setting receiver..." + else + App.statusText = "Setting RX and disconnecting..." + end + local dest = (effective == App.TARGET_TX) and crsf.CONST.ADDRESS_TX or crsf.CONST.ADDRESS_RX + local uid = App.parseUidText(App.phrase) + if uid then + crsf.push(msp.encodeUidWrite(dest, crsf.CONST.ADDRESS_HANDSET, uid)) + else + crsf.push(msp.encodePhraseWrite(dest, crsf.CONST.ADDRESS_HANDSET, App.phrase)) + end + History.add(App.phrase) + if App.bothStep == nil then + defer.setTimeout(FOLLOWUP_TICKS, App.startUidRequest) + else + defer.setTimeout(FOLLOWUP_TICKS, App.sendSet) + end +end + +local function markSent() + App.statusText = "Sent" +end + +--- Put the TX module in bind mode, catching an RX waiting in bind mode. +function App.sendBind() + App.statusText = "Sending bind command..." + crsf:sendBindCommand(crsf.CONST.ADDRESS_TX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) +end + +--- Unbind the connected receiver. +function App.sendUnbind() + App.statusText = "Sending unbind to RX..." + crsf:sendBindCommand(crsf.CONST.ADDRESS_RX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) +end + +function App.useHistory(i) + local phrase = History.items[i] + if phrase == nil then + return + end + App.phrase = phrase + App.rev = App.rev + 1 +end + +function App.removeHistory(i) + History.remove(i) +end + +function App.clearHistory() + History.clear() +end + +--- Frame router for crsf.drain(): the tool is the sole drainer in its Lua +-- state, and the only traffic it consumes is the MSP UID answer. +function App.onFrame(_consumer, command, data) + if command ~= crsf.CONST.FRAMETYPE_MSP_RESP then + return + end + local srcId = msp.decodeUid(data, App.uid) + if srcId == nil then + return + end + defer.clear() + App.uidFrom = srcId + App.statusText = nil + App.uidAttempts = 0 +end + +-- ============================================================================ +-- UI loading (deferred to init) +-- ============================================================================ + +local UI + +-- Module table, forward-declared so init() can drop itself once it has run. +local M = {} + +local function init() + local deps = { + App = App, + crsf = crsf, + msp = msp, + VERSION = VERSION, + versionOk = versionOk, + } + App.phrase = History.items[1] or "" + if useLvgl then + UI = loader("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua", deps) + else + UI = loader("/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua", deps) + end + UI.init() + -- One tick of delay so run()'s first drain creates the telemetry queue + -- before the request's answer can land. + defer.setTimeout(1, App.startUidRequest) + -- 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 gate 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 + + crsf.drain(App, App.onFrame) + -- After the drain, so a callback's push is answered before its follow-up + defer.poll() + + 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/ExpressLRSBind/ui/lcd.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua new file mode 100644 index 0000000..56c2647 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -0,0 +1,348 @@ +---- ######################################################################### +---- # BW LCD UI: Rendering, input handling, cursor management # +---- # For black & white radios (no LVGL required) # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local crsf = deps.crsf +local msp = deps.msp +local VERSION = deps.VERSION + +local TextEdit = loadScript("/SCRIPTS/ELRS/ui/lcd/text_edit.lua")() +local drawAlert = loadScript("/SCRIPTS/ELRS/ui/lcd/alert.lua")() + +-- ============================================================================ +-- UI state +-- ============================================================================ + +-- Row ids (numeric to save RAM). History items are encoded as HIST_BASE + i. +local ROW_PHRASE = 1 +local ROW_TARGET = 2 +local ROW_UID = 3 +local ROW_SET = 4 +local ROW_BIND = 5 -- renders Bind or Unbind depending on the link +local ROW_HISTORY = 6 +local ROW_EXIT = 7 +local ROW_CLEAR = 8 +local ROW_BACK = 9 +local HIST_BASE = 100 + +local PAGE_MAIN = 1 +local PAGE_HISTORY = 2 + +-- Short forms: "Transmitter" would truncate at COL2 on 128 px +local TARGET_NAMES = { "TX", "RX", "Both" } + +local UI = { + -- Cursor/selection state (owned entirely by this module) + page = PAGE_MAIN, + lineIndex = 1, + rows = {}, + editTarget = nil, + ---@type table constructed in init(), needs LCD_W for the window + phraseEdit = nil, + + -- Pending confirmation popup: { msg, i (history index) or nil = clear all } + confirm = nil, + + -- Layout constants for 128x64; UI.init widens COL2 at 212px wide + COL1 = 0, + COL2 = 70, + textSize = 8, + textYoffset = 3, + + -- Redraw state: the page repaints on events and whenever the live status + -- line changes (statusLine below), tracked as a string compare per frame. + forceRedraw = true, + lastStatus = nil, +} + +-- Both pages fit the 8-row 128x64 grid (main 7 rows, history at most 7), +-- so there is no scrolling and no page offset in this UI. + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + end + + -- The phrase window ends at the cursor; 6 px per char of the fixed BW font + UI.phraseEdit = TextEdit.new(msp.CONST.PHRASE_MAX, math.floor((LCD_W - UI.COL2 - 2) / 6)) + UI.phraseEdit.value = App.phrase +end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(event) + if not deps.versionOk then + 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: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + 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 + +-- ============================================================================ +-- Row list +-- ============================================================================ + +local function buildRows() + local rows = UI.rows + for i = #rows, 1, -1 do + rows[i] = nil + end + if UI.page == PAGE_MAIN then + rows[1] = ROW_PHRASE + rows[2] = ROW_TARGET + rows[3] = ROW_UID + rows[4] = ROW_SET + if App.target == App.TARGET_RX then + rows[5] = ROW_BIND + end + rows[#rows + 1] = ROW_HISTORY + rows[#rows + 1] = ROW_EXIT + else + for i = 1, #App.history.items do + rows[#rows + 1] = HIST_BASE + i + end + rows[#rows + 1] = ROW_CLEAR + rows[#rows + 1] = ROW_BACK + end + if UI.lineIndex > #rows then + UI.lineIndex = #rows + end +end + +--- The UID/status row text: compact so it fits 128 px at SMLSIZE. +local function statusLine() + if App.statusText then + return App.statusText + end + local u = App.uid + local prefix = (App.uidFrom == crsf.CONST.ADDRESS_RX) and "RX" or "TX" + return string.format("%s: %d,%d,%d,%d,%d,%d", prefix, u[1], u[2], u[3], u[4], u[5], u[6]) +end + +-- ============================================================================ +-- Navigation and actions +-- ============================================================================ + +local function selectRow(step) + local count = #UI.rows + local idx = UI.lineIndex + step + if idx < 1 then + idx = count + elseif idx > count then + idx = 1 + end + UI.lineIndex = idx +end + +local function goToPage(page) + UI.page = page + UI.lineIndex = 1 + UI.forceRedraw = true +end + +local function handleEnter(id) + if id == ROW_PHRASE then + if App.isTargetReachableOrBoth() then + UI.phraseEdit.value = App.phrase + UI.phraseEdit:start() + end + elseif id == ROW_TARGET then + UI.editTarget = true + elseif id == ROW_UID then + App.startUidRequest() + elseif id == ROW_SET then + if App.isTargetReachableOrBoth() and App.phrase ~= "" then + App.sendSet() + end + elseif id == ROW_BIND then + if crsf.hasTelemetry then + App.sendUnbind() + else + App.sendBind() + end + elseif id == ROW_HISTORY then + goToPage(PAGE_HISTORY) + elseif id == ROW_EXIT then + App.shouldExit = true + elseif id == ROW_CLEAR then + UI.confirm = { msg = "Clear history?" } + elseif id == ROW_BACK then + goToPage(PAGE_MAIN) + elseif id > HIST_BASE then + App.useHistory(id - HIST_BASE) + UI.phraseEdit.value = App.phrase + goToPage(PAGE_MAIN) + end +end + +local function handleEvent(event) + -- Phrase editing captures every event until it commits + if UI.phraseEdit.editing then + if UI.phraseEdit:handleEvent(event) then + App.phrase = UI.phraseEdit.value + end + return + end + + -- Target edit: rotary cycles, ENTER or EXIT commits + if UI.editTarget then + if event == EVT_VIRTUAL_NEXT then + App.target = math.min(App.target + 1, App.TARGET_BOTH) + elseif event == EVT_VIRTUAL_PREV then + App.target = math.max(App.target - 1, App.TARGET_TX) + elseif event == EVT_VIRTUAL_ENTER or event == EVT_VIRTUAL_EXIT then + UI.editTarget = nil + end + return + end + + if event == EVT_VIRTUAL_EXIT then + if UI.page == PAGE_HISTORY then + goToPage(PAGE_MAIN) + else + App.shouldExit = true + end + elseif event == EVT_VIRTUAL_ENTER then + handleEnter(UI.rows[UI.lineIndex]) + elseif event == EVT_VIRTUAL_ENTER_LONG then + killEvents(event) + local id = UI.rows[UI.lineIndex] + if id and id > HIST_BASE then + UI.confirm = { msg = "Delete entry?", i = id - HIST_BASE } + end + elseif event == EVT_VIRTUAL_NEXT then + selectRow(1) + elseif event == EVT_VIRTUAL_PREV then + selectRow(-1) + end +end + +-- ============================================================================ +-- Rendering +-- ============================================================================ + +local function drawTitle() + lcd.drawFilledRectangle(0, 0, LCD_W, UI.textSize + 1, GREY_DEFAULT) + lcd.drawText(UI.COL1 + 1, 1, "ELRS Bind " .. VERSION, INVERS) + -- Link flag: C while RX telemetry is alive, - otherwise + lcd.drawText(LCD_W - 1, 1, crsf.hasTelemetry and "C" or "-", INVERS + RIGHT) +end + +local function drawRow(id, yPos, isSelected) + local attr = isSelected and INVERS or 0 + if id == ROW_PHRASE then + lcd.drawText(UI.COL1, yPos, "Phrase", 0) + UI.phraseEdit:draw(UI.COL2, yPos, attr) + elseif id == ROW_TARGET then + if UI.editTarget and isSelected then + attr = attr + BLINK + end + lcd.drawText(UI.COL1, yPos, "Target", 0) + lcd.drawText(UI.COL2, yPos, TARGET_NAMES[App.target], attr) + elseif id == ROW_UID then + lcd.drawText(UI.COL1, yPos, statusLine(), attr + SMLSIZE) + elseif id == ROW_SET then + lcd.drawText(10, yPos, "[Set]", attr + BOLD) + elseif id == ROW_BIND then + lcd.drawText(10, yPos, crsf.hasTelemetry and "[Unbind]" or "[Bind]", attr + BOLD) + elseif id == ROW_HISTORY then + lcd.drawText(UI.COL1, yPos, "> History", attr + BOLD) + elseif id == ROW_EXIT then + lcd.drawText(10, yPos, "[---- EXIT ----]", attr + BOLD) + elseif id == ROW_CLEAR then + lcd.drawText(10, yPos, "[Clear All]", attr + BOLD) + elseif id == ROW_BACK then + lcd.drawText(10, yPos, "[---- BACK ----]", attr + BOLD) + elseif id > HIST_BASE then + lcd.drawText(UI.COL1, yPos, App.history.items[id - HIST_BASE] or "", attr) + end +end + +local function drawPage(event) + handleEvent(event) + buildRows() + + lcd.clear() + drawTitle() + + for i = 1, #UI.rows do + drawRow(UI.rows[i], i * UI.textSize + UI.textYoffset, UI.lineIndex == i) + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +function UI.render(event, _touchState) + -- Pending confirmation owns the screen until answered. It arms only after + -- a quiet frame: the ENTER release that follows the long press which + -- opened it would otherwise answer it on the spot. + if UI.confirm then + local result = popupConfirmation(UI.confirm.msg, "PRESS [OK] to confirm", event) + if not UI.confirm.armed then + if event == 0 then + UI.confirm.armed = true + end + return + end + if result == "OK" then + if UI.confirm.i then + App.removeHistory(UI.confirm.i) + else + App.clearHistory() + end + UI.confirm = nil + UI.forceRedraw = true + elseif result == "CANCEL" then + UI.confirm = nil + UI.forceRedraw = true + end + return + end + + -- Repaint on any event, while editing, and whenever the live parts of the + -- page (status line, link flag) changed since the last paint. + local status = statusLine() .. (crsf.hasTelemetry and "C" or "-") + if event ~= 0 or UI.forceRedraw or UI.phraseEdit.editing or status ~= UI.lastStatus then + drawPage(event) + UI.lastStatus = status + UI.forceRedraw = false + end +end + +return UI diff --git a/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua new file mode 100644 index 0000000..3b45b45 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -0,0 +1,253 @@ +---- ######################################################################### +---- # LVGL UI: Color LCD rendering for the bind phrase manager # +---- # For color LCD radios with EdgeTX 2.11.6+/2.12.1+ LVGL support # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local crsf = deps.crsf +local msp = deps.msp + +local SharedDialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + +-- ============================================================================ +-- UI state +-- ============================================================================ + +local UI = { + -- App.rev the current page was built for; nil forces the first build. + builtRev = nil, + -- Guards the one-shot version/no-module dialogs. + dialogBuilt = false, +} + +-- ============================================================================ +-- Main page +-- ============================================================================ + +local function exitTool() + App.shouldExit = true +end + +local function isSetEnabled() + return App.isTargetReachableOrBoth() and App.phrase ~= "" +end + +local function isRxSelected() + return App.target == App.TARGET_RX +end + +local function isRxSelectedConnected() + return App.target == App.TARGET_RX and crsf.hasTelemetry +end + +local function isRxSelectedDisconnected() + return App.target == App.TARGET_RX and not crsf.hasTelemetry +end + +local function buildUi() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS Bind Phrase", + subtitle = App.uidLine, + back = exitTool, + }) + + local tbox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + }) + + -- ***** Bind Phrase label + text edit + Set button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Bind phrase", + children = { + { + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.TEXT_EDIT, + w = 250 * lvgl.LCD_SCALE, + value = App.phrase, + -- A phrase must fit one un-chunked MSP_WRITE frame + length = msp.CONST.PHRASE_MAX, + set = function(v) + App.phrase = v + end, + active = App.isTargetReachableOrBoth, + }, + { + type = lvgl.BUTTON, + text = "Set", + press = App.sendSet, + active = isSetEnabled, + }, + }, + }, + }, + }) + + -- ***** Target label + dropdown + Request UID button ***** + tbox:setting({ + w = lvgl.PERCENT_SIZE + 100, + title = "Target", + children = { + { + type = lvgl.BOX, + x = 120 * lvgl.LCD_SCALE, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + children = { + { + type = lvgl.CHOICE, + title = "Select Target", + values = { "Transmitter", "Receiver", "Both" }, + get = function() + return App.target + end, + set = function(n) + App.target = n + end, + }, + { + type = lvgl.BUTTON, + text = "Request UID", + press = App.startUidRequest, + active = App.isTargetReachable, + }, + { + type = lvgl.BUTTON, + text = "Unbind", + press = App.sendUnbind, + visible = isRxSelected, + active = isRxSelectedConnected, + }, + }, + }, + }, + }) + + -- ***** Show Bind button if RX target selected and no RX connected ***** + pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_MEDIUM, + align = LEFT, + visible = isRxSelectedDisconnected, + children = { + { + type = lvgl.LABEL, + w = 4 + lvgl.LCD_SCALE * (120 + 250), + text = " No receiver connected.\n Use Bind to set bindphrase if RX in bind mode", + }, + { + type = lvgl.BUTTON, + text = "Bind", + press = App.sendBind, + }, + }, + }) + + -- ***** Bind Phrase History ***** + local histSection = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + y = 2 * lvgl.UI_ELEMENT_HEIGHT + 4 * lvgl.PAD_MEDIUM, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = 0, + visible = function() + return App.history.items[1] ~= nil and App.isTargetReachableOrBoth() + end, + }) + histSection:label({ + text = "Bind Phrase History", + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + }) + for i = 1, App.history.MAX do + local row = histSection:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_SMALL, + visible = function() + return App.history.items[i] ~= nil + end, + }) + -- Button containing a history item with its value + row:button({ + w = lvgl.PERCENT_SIZE + 80, + text = function() + return App.history.items[i] or "" + end, + press = function() + App.useHistory(i) + end, + }) + -- Button X to delete an item + row:button({ + text = "X", + textColor = COLOR_THEME_WARNING, + press = function() + App.removeHistory(i) + end, + }) + end +end + +-- ============================================================================ +-- Interface: init +-- ============================================================================ + +function UI.init() end + +-- ============================================================================ +-- Interface: preCheck (version gate) +-- ============================================================================ + +function UI.preCheck(_event) + if not deps.versionOk then + if not UI.dialogBuilt then + SharedDialogs.showVersionRequired(exitTool) + UI.dialogBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 + end + + return nil +end + +-- ============================================================================ +-- Interface: handleNoModule +-- ============================================================================ + +function UI.handleNoModule() + if not UI.dialogBuilt then + SharedDialogs.showNoModule(exitTool) + UI.dialogBuilt = true + end +end + +-- ============================================================================ +-- Interface: render +-- ============================================================================ + +-- Rebuild whenever App.rev moved: TEXT_EDIT's value is a build-time +-- snapshot, so a history fill or the Both flow's target flip only shows +-- through a fresh build. +function UI.render(_event, _touchState) + if UI.builtRev ~= App.rev then + buildUi() + UI.builtRev = App.rev + end +end + +return UI diff --git a/src/WIDGETS/ELRSTelemetry/loadable.lua b/src/WIDGETS/ELRSTelemetry/loadable.lua new file mode 100644 index 0000000..d00fa9e --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -0,0 +1,110 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Wiring -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with -- +-- (zone, options, Telemetry); returns the widget instance table. -- +-- -- +-- One of these exists per placed widget. It owns no state of its own: -- +-- everything it shows lives in the Telemetry singleton, which outlives -- +-- it. All this file does is pick the layout for the screen, wire the -- +-- components together, and drive them from the widget callbacks. -- +-- -- +-- The minimized layout is per screen size; the full-screen page is -- +-- shared, because lvgl.page() handles the responsive part itself. -- +--------------------------------------------------------------------------- + +local zone, options, Telemetry = ... + +-- ============================================================================ +-- Display components +-- ============================================================================ + +local Display = loadScript("/WIDGETS/ELRSTelemetry/ui/display.lua")(Telemetry) +local Components = loadScript("/WIDGETS/ELRSTelemetry/ui/components.lua")(Display) + +-- ============================================================================ +-- 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)({ + Display = Display, + bgOpacity = bgOpacity, + Components = Components, +}) + +-- ============================================================================ +-- Widget lifecycle +-- ============================================================================ + +local wgt = { + zone = zone, + options = options, +} + +-- drain() is per instance and ungated: this instance owns a pop queue in the +-- firmware that only it can empty. update() is shared and samples at most once +-- per tick, so the instances after the first fall through it. +function wgt.background() + Telemetry.drain() + Telemetry.update() +end + +function wgt.refresh(_event, _touchState) + wgt.background() +end + +local FullScreenUI + +--- Build the full-screen page, loading it the first time it is asked for. +--- Only one widget can be full screen at a time, so loading it eagerly would +--- leave a page builder resident in every instance that never shows one. +--- Nothing is lost by waiting: update() is not a hot path -- EdgeTX calls it +--- on construction, on entering and leaving full screen, and on an options +--- edit -- so a loadScript here costs nothing. +local function buildFullScreen() + if not FullScreenUI then + FullScreenUI = loadScript("/WIDGETS/ELRSTelemetry/ui/fullscreen.lua")(Telemetry, Display) + end + FullScreenUI.build() +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..6f310c6 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -0,0 +1,68 @@ +--------------------------------------------------------------------------- +-- 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 transport. -- +--------------------------------------------------------------------------- + +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 _elrsTelemetrySingleton then + local getTelemetry = loadScript(table.concat({ "/WIDGETS/", name, "/telemetry.lua" })) + ---@diagnostic disable-next-line: need-check-nil + _elrsTelemetrySingleton = getTelemetry(_crsfSingleton) + end + -- Every model has its own widgets, so create() runs again for each of them + -- on a model change -- but the singleton above does not go with them. It is + -- a global in the widget Lua state, which only boot and resume from shutdown + -- rebuild, so the previous model's aircraft would still be on screen: its + -- last GPS position above all, indistinguishable from a live fix. + -- + -- Keyed on the model, not on create() alone, because create() also runs when + -- a widget is placed: adding a second telemetry widget must not throw away + -- the cell count and position the first one has locked on to. Two models + -- with the same name share a verdict, which is as far as this needs to go. + local modelId = model.getInfo().name + if _elrsTelemetrySingleton.modelId ~= modelId then + _elrsTelemetrySingleton.modelId = modelId + _elrsTelemetrySingleton.resetModel() + end + -- Cheap, idempotent and self-repairing, so every create() may ask again. + _elrsTelemetrySingleton.resetModelMatch() + local loadable = loadScript(table.concat({ "/WIDGETS/", name, "/loadable.lua" })) + ---@diagnostic disable-next-line: need-check-nil + return loadable(zone, options, _elrsTelemetrySingleton) +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/rf_modes.lua b/src/WIDGETS/ELRSTelemetry/rf_modes.lua new file mode 100644 index 0000000..6e8f71f --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/rf_modes.lua @@ -0,0 +1,202 @@ +--------------------------------------------------------------------------- +-- ELRS RF Mode Tables -- +-- Loaded via loadScript() from ELRSTelemetry/telemetry.lua with no -- +-- arguments; returns the RfModes table. -- +-- -- +-- Pure data plus its selector: the packet-rate names ExpressLRS reports -- +-- through the RFMD sensor, and the receiver sensitivity floor each rate -- +-- is rated to. Both are keyed by the module's firmware major version and -- +-- change on ExpressLRS's release clock, which is why they live apart -- +-- from the polling policy that consumes them. -- +-- -- +-- RFMD is a 0-based sensor and these are 1-based Lua arrays, so the +1 -- +-- lives here, next to the literals that define the convention. Callers -- +-- pass the raw sensor value. -- +--------------------------------------------------------------------------- + +local RfModes = {} + +-- Names and floors for the currently selected major version, or nil while no +-- ELRS module has answered a device ping. +---@type table? +RfModes._names = nil +---@type table? +RfModes._floors = nil + +-- Effective major version of the tables currently built. The only version +-- datum kept, purely so select() can skip rebuilds. +---@type number? +RfModes._maj = nil + +-- ============================================================================ +-- Selection +-- ============================================================================ + +--- Install the 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. +function RfModes.select(vMaj) + local effMaj + if vMaj >= 4 then + effMaj = 4 + elseif vMaj == 3 then + effMaj = 3 + end + if RfModes._maj == effMaj then + return + end + RfModes._maj = effMaj + + if effMaj == 4 then + -- selene: allow(mixed_table) + RfModes._names = { + "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) + RfModes._floors = { + -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 + RfModes._names = { + "", + "25Hz", + "50Hz", + "100Hz", + "100HzFull", + "150Hz", + "200Hz", + "250Hz", + "333HzFull", + "500Hz", + "D250", + "D500", + "F500", + "F1000", + "D50", + "200HzFull", + "DK500", + "K1000", + "9K1000", + "K1000Full", + } + RfModes._floors = { + 0, + -123, + -115, + -117, + -112, + -112, + -112, + -108, + -105, + -105, + -104, + -104, + -104, + -104, + -112, + -111, + -103, + -103, + 0, + -101, + } + else + RfModes._names = nil + RfModes._floors = nil + end +end + +-- ============================================================================ +-- Lookup +-- ============================================================================ + +--- Packet-rate name for an RFMD sensor value. +-- Falls back to "RFMD" for a rate this firmware version's table does not +-- name, and for every rate while no module has answered yet. +function RfModes.name(rfmd) + local names = RfModes._names + return (names and names[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) +end + +--- Rated receiver sensitivity in dBm for an RFMD sensor value, or nil when +--- the rate is unknown. Rates the tables carry as 0 are unrated, not 0 dBm. +function RfModes.floor(rfmd) + local floors = RfModes._floors + local dbm = floors and floors[rfmd + 1] + -- 0 is the tables' placeholder for a rate ExpressLRS publishes no figure + -- for. It has to become nil here: 0 is a truthy number, so callers guarding + -- with `or ` would take it for a real sensitivity of 0 dBm. + if dbm == 0 then + return nil + end + return dbm +end + +-- ============================================================================ +-- Return module +-- ============================================================================ + +return RfModes diff --git a/src/WIDGETS/ELRSTelemetry/telemetry.lua b/src/WIDGETS/ELRSTelemetry/telemetry.lua new file mode 100644 index 0000000..cb8a04e --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -0,0 +1,493 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry State -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- +-- returns the Telemetry singleton. -- +-- -- +-- Everything the widget knows about the link, in one owner: the frame -- +-- drain, the DEVICE_INFO cache and the RF mode tables it selects, the -- +-- model-match status and the rules for when it may be asked for, the -- +-- per-tick sensor snapshot, and every value derived from it. -- +-- -- +-- All of that describes the radio's link, not a widget instance, so it -- +-- is loaded once per Lua state and shared by every instance. Three -- +-- things follow, and none of them are optional: -- +-- -- +-- 1. drain() runs per instance, every frame, ungated. Each widget -- +-- instance owns a private pop queue in the firmware, so skipping it -- +-- for the instances that did not sample this tick would leave those -- +-- queues to fill and overflow. -- +-- 2. update() samples once per tick however many instances call it. -- +-- Without that, the headroom smoother would step once per instance -- +-- per frame and settle N times faster on a screen with N widgets. -- +-- 3. The frame handlers stay pure assignment. Every instance is -- +-- delivered its own copy of each frame, so on a radio this singleton -- +-- decodes the same frame N times; a counter here would be N times -- +-- wrong on hardware and right in the simulator. -- +-- -- +-- Per background tick: Telemetry.drain() to ingest the queue (which -- +-- also refreshes crsf.hasTelemetry as it empties), then -- +-- Telemetry.update() to sample and pump the outgoing requests. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() + +local Telemetry = {} + +-- ============================================================================ +-- State +-- ============================================================================ + +-- Sensor snapshot, refilled once per tick by update(). The table identity +-- never changes, so consumers may cache a reference. +Telemetry.link = {} + +-- Smoothed signal headroom as a percentage of the current RF mode's usable +-- range, or nil while that range is unknown. Recomputed once per tick. +---@type number? +Telemetry.headroomPct = nil + +-- Detected battery cell count, or nil while no plausible pack voltage has +-- been seen. Locks on once the same count survives a few readings. +---@type number? +Telemetry.cellCnt = nil + +-- Last known GPS position. Deliberately survives a dropout -- it is what you +-- read when looking for a model that stopped answering -- so only a model +-- change clears it. +---@type {lat: number, lon: number}|nil +Telemetry.gps = nil + +-- Model match comes from the ELRS_STATUS answers updateModelMatch() asks for. +-- The module recomputes the flag as it answers and never sends one unasked, so +-- the value is exactly as old as the last answer. +---@type boolean? +Telemetry.modelMismatch = nil + +-- Model this state belongs to, so create() can tell a model change from a +-- second widget being placed on the model already loaded. +---@type string? +Telemetry.modelId = nil + +-- Cell count detection working state. +---@type number? +Telemetry._cellCntCnt = nil +Telemetry._cellLastV = nil + +-- Running smoother state for headroomPct. +---@type number? +Telemetry._smoothHead = nil + +-- Device info cache (populated by the DEVICE_INFO handler): name, isElrs. +-- Neither is displayed: name is the "already answered" latch that stops the +-- ping, isElrs is the gate on rule 2 below. +Telemetry._device = {} + +-- Device info polling +Telemetry._lastDevPoll = 0 + +-- ELRS status polling +Telemetry._lastStatusPoll = 0 + +-- Set once an ELRS_STATUS answer has arrived, so a match is asked about once +-- rather than polled. Cleared on any hasTelemetry edge in update(), and by +-- resetModelMatch() on widget create. +---@type boolean? +Telemetry._statusAnswered = nil + +-- hasTelemetry as last seen by update(), to detect connection edges. +Telemetry._wasConnected = false + +-- getTime() of the last sample, for the once-per-tick guard in update(). +---@type number? +Telemetry._sampledAt = nil + +-- ============================================================================ +-- Queries +-- ============================================================================ + +--- Whether a CRSF/ELRS module is available at all. +function Telemetry.hasModule() + return crsf.hasCrsfModule() +end + +--- Whether the link is up, i.e. the receiver is answering. +function Telemetry.isConnected() + return crsf.hasTelemetry +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 Telemetry.modelMismatch +end + +-- The link's state as one value, worst first. The view turns it into words in +-- one place and into a colour in another; both index this, so the status line +-- and the status LED cannot end up disagreeing about what the link is doing. +Telemetry.STATUS = { + NO_MODULE = 1, + NO_TELEMETRY = 2, + MISMATCH = 3, + OK = 4, +} + +--- Where the link currently sits on the STATUS ladder. +function Telemetry.statusLevel() + local STATUS = Telemetry.STATUS + if not Telemetry.hasModule() then + return STATUS.NO_MODULE + end + if not Telemetry.isConnected() then + return STATUS.NO_TELEMETRY + end + if Telemetry.modelMismatch then + return STATUS.MISMATCH + end + return STATUS.OK +end + +--- RSSI of the antenna currently in use, or nil while unknown. +function Telemetry.activeRssi() + local link = Telemetry.link + return (link.ant == 1) and link.rssi2 or link.rssi1 +end + +--- dB the active antenna sits above the RF mode's rated sensitivity floor, or +--- nil while either end is unknown. This is the number the headroom bar draws +--- and the full-screen page prints: RSSI on its own says nothing until you +--- know what the receiver can still hear at. +function Telemetry.marginDb() + local rssi = Telemetry.activeRssi() + local sens = Telemetry.link.sens + if rssi == nil or sens == nil then + return nil + end + return rssi - sens +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 + +--- Packet-rate name for the current RF mode, e.g. "250Hz". +function Telemetry.rfModeName() + local rfmd = Telemetry.link.rfmd + if rfmd == nil then + return nil + end + return RfModes.name(rfmd) +end + +-- ============================================================================ +-- Derived state +-- ============================================================================ + +--- Cell count detection heuristic. +local function checkCellCount(v) + -- once the cell count 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 + +-- Above this the receiver is saturated and more signal buys nothing, so it is +-- where the headroom scale tops out rather than the strongest RSSI seen. +-- Public because it is the headroom bar's right endpoint, and a layout file +-- printing its own -50 would be a second copy of the same decision. +Telemetry.RSSI_CEILING = -50 +local RSSI_CEILING = Telemetry.RSSI_CEILING + +--- Recompute the smoothed signal headroom from the snapshot. +-- 0% puts the active antenna exactly on the RF mode's rated floor and 100% +-- puts it at the ceiling, so the scale recalibrates with the packet rate: the +-- same -80 dBm is comfortable at 25Hz and marginal at F1000. +-- Unknown at either end means there is no scale to place RSSI on, and a bar +-- drawn against a guessed floor would be worse than no bar. +local function updateHeadroom() + local rssi = Telemetry.activeRssi() + local sens = Telemetry.link.sens + if rssi == nil or sens == nil then + Telemetry.headroomPct = nil + Telemetry._smoothHead = nil + return + end + if rssi > RSSI_CEILING then + rssi = RSSI_CEILING + elseif rssi < sens then + -- The floor is a rating, not a wall: a receiver still reports RSSI below + -- the figure it is rated to. Clamping keeps the percentage on scale. + rssi = sens + end + local pct = math.floor(100 * (rssi - sens) / (RSSI_CEILING - sens) + 0.5) + local smooth = Telemetry._smoothHead 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._smoothHead = pct + Telemetry.headroomPct = pct +end + +--- Latch the last known GPS position, ignoring the "no fix" readings. +local function updateGps() + local gps = crsf.getSensorValue("GPS") + if gps and gps ~= 0 then + Telemetry.gps = gps + end +end + +-- ============================================================================ +-- Resets +-- ============================================================================ + +--- Clear the state that belongs to one connection, on the falling edge. +--- Cell count and headroom smoothing re-detect on the next battery instead of +--- latching forever. gps is deliberately kept. +local function resetConnection() + Telemetry._smoothHead = nil + Telemetry.headroomPct = nil + Telemetry.cellCnt = nil + Telemetry._cellCntCnt = nil + Telemetry._cellLastV = nil +end + +--- Forget the model-match verdict, so the next tick asks for a fresh one. +-- Cheap, idempotent and self-repairing, so create() may call it every time. +function Telemetry.resetModelMatch() + Telemetry.modelMismatch = nil + Telemetry._statusAnswered = nil +end + +--- Forget everything that belongs to the model that was loaded before. +-- This singleton lives in the widget Lua state, which only a boot or a resume +-- from shutdown rebuilds, while widget instances are rebuilt on every model +-- change -- so without this the previous model's aircraft would be on screen: +-- its last GPS position in particular, indistinguishable from a live fix. +-- The device cache and the RF tables stay; the module did not change. +-- +-- Clearing _sampledAt is load-bearing. create() runs per instance, so if a +-- sibling already sampled this tick, the update() that follows this reset +-- would be swallowed by the guard and paint a cleared snapshot for one frame. +function Telemetry.resetModel() + resetConnection() + Telemetry.resetModelMatch() + Telemetry.gps = nil + Telemetry._wasConnected = false + Telemetry._sampledAt = nil + crsf.resetSensorCache() +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 Telemetry._device.name then + return + end + local now = getTime() + if now - Telemetry._lastDevPoll < 100 then + return + end + Telemetry._lastDevPoll = now + crsf:pingDevices(crsf.CONST.ADDRESS_TX) +end + +-- Weakest link a status request will be spent on (rule 4 below). +local MODEL_MATCH_MIN_RSSI = -70 + +--- Whether a status request is worth the frame it costs, right now. +-- requestElrsStatus() is answered locally by the module, but the push still +-- replaces one RC-channels frame on the handset->module UART, so every rule +-- below weighs one answer against one frame. All of them must hold. +local function canRequestStatus(now) + -- 1. Connected. The module reports no model-match verdict without a link, + -- and the answer would carry nothing to display. + if not crsf.hasTelemetry then + return false + end + -- 2. ExpressLRS. The fieldId=0 request is an ELRS convention; a TBS module + -- answers device pings but never this, and would be asked forever. + if not Telemetry._device.isElrs then + return false + end + -- 3. No answer yet, or the last one was a mismatch. One answer settles a + -- match, but the module recomputes the flag as it answers and announces + -- nothing on its own, while every way out of a mismatch -- receiver + -- number, model select, the tool's Model Match switch, a rebind -- is + -- applied over a link the handset never sees drop. Latching there would + -- pin the warning on screen for the rest of the session. + if Telemetry._statusAnswered and not Telemetry.modelMismatch then + return false + end + -- 4. Strong link. A mismatch is caught next to the quad; out at range every + -- RC-channels frame is worth more than the answer is. Read from the + -- snapshot this tick's sample already filled. + local rssi = Telemetry.activeRssi() + if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then + return false + end + -- 5. At most once per second, which is as fresh as an answer gets: the + -- module latches its own packet counters on a 1 s watchdog. + return now - Telemetry._lastStatusPoll >= 100 +end + +--- Keep modelMismatch current, one request per second at the very most. +local function updateModelMatch() + local now = getTime() + if not canRequestStatus(now) then + return + end + Telemetry._lastStatusPoll = now + crsf:requestElrsStatus() +end + +-- ============================================================================ +-- Per-tick pump +-- ============================================================================ + +-- getTime() ticks every 10 ms and the widget loop runs every 50 ms, so a +-- window of 3 ticks deduplicates the instances of one frame without ever +-- swallowing the next frame -- an equality test would miss an instance loop +-- that straddles a tick boundary. +local SAMPLE_INTERVAL = 3 + +--- Sample every sensor and recompute everything derived from them, once per +--- tick however many instances call this. +--- Call right after drain() so the tick's frames are ingested 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. hasTelemetry only refreshes as a drain empties the +--- queue, so a dying connection's ELRS_STATUS always lands before the edge is +--- observed here. +function Telemetry.update() + local now = getTime() + if now - (Telemetry._sampledAt or -SAMPLE_INTERVAL) < SAMPLE_INTERVAL then + return + end + Telemetry._sampledAt = now + + 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.tqly = crsf.getSensorValue("TQly") + link.trss = crsf.getSensorValue("TRSS") + link.vbat = crsf.getSensorValue("RxBt") + link.curr = crsf.getSensorValue("Curr") + link.fm = crsf.getSensorValue("FM") + link.sats = crsf.getSensorValue("Sats") + link.gspd = crsf.getSensorValue("GSpd") + link.alt = crsf.getSensorValue("Alt") + -- Resolved here rather than at every read: the floor only moves when the + -- packet rate does, and the view asks for it several times a frame. + link.sens = link.rfmd and RfModes.floor(link.rfmd) + + local connected = crsf.hasTelemetry + if connected ~= Telemetry._wasConnected then + Telemetry._wasConnected = connected + Telemetry.resetModelMatch() + if not connected then + resetConnection() + end + end + + if connected then + updateGps() + updateHeadroom() + if link.vbat then + checkCellCount(link.vbat) + end + end + + requestDeviceInfo() + updateModelMatch() +end + +-- ============================================================================ +-- Frame handlers +-- ============================================================================ + +-- DEVICE_INFO handler: caches module 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 + Telemetry._device.name = info.name + Telemetry._device.isElrs = info.isElrs + RfModes.select(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 + Telemetry._statusAnswered = true + Telemetry.modelMismatch = status.modelMismatch +end + +--- Route one frame into the state. Frames of other types are dropped: this +-- singleton is its widget's only queue consumer (pop is destructive per +-- instance), and the firmware delivers every instance its own copy of each +-- frame. Assignment only -- see invariant 3 in the header. +function Telemetry:_onFrame(command, data) + if command == crsf.CONST.FRAMETYPE_DEVICE_INFO then + onDeviceInfo(data) + elseif command == crsf.CONST.FRAMETYPE_ELRS_STATUS then + onElrsStatus(data) + end +end + +--- Drain the calling script instance's pop queue into the state. +function Telemetry.drain() + crsf.drain(Telemetry, Telemetry._onFrame) +end + +-- ============================================================================ +-- Return singleton +-- ============================================================================ + +return Telemetry diff --git a/src/WIDGETS/ELRSTelemetry/ui/components.lua b/src/WIDGETS/ELRSTelemetry/ui/components.lua new file mode 100644 index 0000000..eeadce1 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -0,0 +1,951 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Drawing Components -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Display); returns the Components table. -- +-- -- +-- Two layers. Elements are the shapes -- panel, bar, led, antenna, -- +-- label. Blocks are what a tier actually places: the header row, the -- +-- LQ hero, a captioned value row, the reading grid. Keeping the blocks -- +-- here is what makes five per-screen files affordable: a tier builder -- +-- stays a short vertical composition instead of eight inlined element -- +-- definitions copied five times. -- +-- -- +-- Nothing here calls lvgl.build(). Every function appends descriptor -- +-- tables to a list its caller owns, and the tier builds once at the -- +-- end: one C traversal, no retained handles, and no giant nested -- +-- literal for the .luac precompiler to choke on. -- +-- -- +-- Every block returns the next free y, so a tier reads top to bottom. -- +-- -- +-- Only label, rectangle and circle appear below. The others lvgl offers -- +-- are either fullscreen-only -- silently skipped in a widget zone, and -- +-- lvgl.build() fails without a word when that happens -- or, like -- +-- hline, have no thickness and so cannot draw a rule. -- +--------------------------------------------------------------------------- + +local Display = ... + +local Components = {} + +-- How solid a bar's unfilled track is. Enough to read the bar's full +-- extent, not so much that the empty half draws the eye before the fill. +local TRACK_OPACITY = 90 + +-- The widget's own name, and the short form for a zone too narrow for it. +-- Written the way ExpressLRS writes it rather than uppercased like the +-- captions further down: this is a name, not a caption, and the same reason +-- keeps the mixed case in TQly and TRSS. +local BRAND = "ExpressLRS" +local BRAND_SHORT = "ELRS" + +-- The mismatch banner, and the short form for a header that cannot carry the +-- long one. Caps either way: this is the one thing on the header worth +-- interrupting for. +local MISMATCH = "MODEL MISMATCH!" +local MISMATCH_SHORT = "MISMATCH!" + +-- The signal fit ladder's reserve strings: the widest string each form can +-- render, so a narrowing row trades forms at a stable width instead of +-- reflowing with the values. +local SIGNAL_FULL = "-105 -105 / -105 dBm" +local SIGNAL_SHORT = "-105 / -105 dBm" + +--- Slot side of one antenna dot. Half the row, so the pair sits at the status +--- LED's weight rather than the text's. +local function antCell(m) + return math.floor(m.sml / 2) + 1 +end + +-- ============================================================================ +-- Metrics +-- ============================================================================ + +--- Measure the font line heights a layout needs. +-- Measured rather than tabulated because the 800x480 target ships different +-- font files: a height taken on 480x272 and scaled by lvgl.LCD_SCALE would be +-- confidently wrong there. lcd.sizeText has no LCD-buffer guard, so it is one +-- of the few lcd.* calls that still works under LVGL. +-- "0" is measured rather than the real strings: line height is a property of +-- the font, and using content would make the metric move with the data. +function Components.measure() + local m = { + pad = lvgl.PAD_SMALL, + gap = lvgl.PAD_TINY, + lines = {}, + } + m.sml = Components.lineHeight(m, SMLSIZE) + m.bold = Components.lineHeight(m, BOLD) + m.mid = Components.lineHeight(m, MIDSIZE) + return m +end + +--- Line height of a font, measured once per font and cached on the metrics +--- table. This is what lets a screen file declare its hero ladder as font +--- names alone: the height half of the pair is always the measured truth. +function Components.lineHeight(m, font) + local h = m.lines[font] + if h == nil then + h = select(2, lcd.sizeText("0", font)) + m.lines[font] = h + end + return h +end + +--- Width of a string in a font, for reserving a column. +function Components.textWidth(s, font) + return (lcd.sizeText(s, font)) +end + +--- Split a widget zone into the panel rect and the content rect inside it. +--- The panel fills the zone and the breathing room is padding inside it. A +--- widget that inset its panel instead would sit a couple of pixels in from +--- every neighbour on the screen, and the gap reads as a misalignment rather +--- than as space -- the zone boundaries are the screen's grid, and they are not +--- this widget's to redraw. +--- The padding is lvgl.PAD_SMALL because that is the borderPad the ELRS VTX +--- Admin widget's container uses (ELRSVTXAdmin/ui/display.lua:19-44), so the +--- two stack with their text on one left edge. It also comes off the theme's +--- own scale rather than being 4px on a 480 and 4px on an 800. +--- panel is absolute; inner is relative to the panel, because lvgl positions +--- children against their parent's origin. +function Components.frame(w, h, m) + return { + panel = { x = 0, y = 0, w = w, h = h }, + inner = { x = m.pad, w = w - m.pad * 2 }, + pad = m.pad, + h = h, + } +end + +--- The widget's name in the longest form that fits availW, or nil when even +--- the short one does not. +--- A ladder rather than a per-radio breakpoint: what fits is a measured width +--- in this screen's font. +function Components.brandText(availW) + if availW >= Components.textWidth(BRAND, SMLSIZE) then + return BRAND + end + if availW >= Components.textWidth(BRAND_SHORT, SMLSIZE) then + return BRAND_SHORT + end + return nil +end + +--- The largest hero font whose line box fits the budget, with its measured +--- height. The ladder exists because one tier builder serves more than one +--- zone height -- on 480x272 the 1/1 composition draws both the 227px full +--- zone and the 113px half zone -- so the hero has to be picked from what the +--- zone can afford, not declared per tier. The last rung is the floor: it is +--- taken even when over budget, because a panel with no hero is not this +--- widget. +function Components.heroFromLadder(m, ladder, budget) + for i = 1, #ladder do + local font = ladder[i] + local fh = Components.lineHeight(m, font) + if fh <= budget then + return font, fh + end + end + local last = ladder[#ladder] + return last, Components.lineHeight(m, last) +end + +-- ============================================================================ +-- Elements +-- ============================================================================ + +--- Background panel. Returns the child list to compose into, so everything +--- placed afterwards is positioned relative to the panel's own origin. +--- A fill and a separate container, the same shape the VTX Admin widget's +--- WidgetLayout uses, and for the same two reasons: opacity applies to the +--- object rather than only its background, so the content cannot live inside +--- the translucent rectangle, and a borderless container puts the content +--- origin exactly on the zone corner. An outlined panel was tried and reads as +--- a card glued over the background while every widget beside it is painted on +--- -- and a border also eats a pixel off each side of the content box, which is +--- enough to make symmetric padding come out lopsided. +function Components.panel(dst, rect, spec) + local children = {} + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = rect.y, + w = rect.w, + h = rect.h, + color = COLOR_THEME_PRIMARY2, + opacity = spec.opacity, + filled = true, + } + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = rect.y, + w = rect.w, + h = rect.h, + thickness = 0, + children = children, + } + return children +end + +--- A text label. Thin wrapper, so a tier never hand-writes a descriptor. +function Components.label(dst, spec) + dst[#dst + 1] = { + type = lvgl.LABEL, + x = spec.x, + y = spec.y, + w = spec.w, + align = spec.align or LEFT, + font = spec.font, + color = spec.color or COLOR_THEME_PRIMARY1, + text = spec.text, + visible = spec.visible, + } +end + +--- A horizontal meter: a track with a fill that grows from the left. +--- spec.pct returns 0-100; spec.color is the fill colour; spec.visible, when +--- given, hides the fill so the bare track shows through. +function Components.bar(dst, rect, y, spec) + local h = spec.h + local w = rect.w + -- Pill ends. rounded is a build-time corner radius, which is fine here: the + -- bar's height is fixed per build, and LVGL clamps the radius itself when + -- the fill's live width drops below the pill's diameter. + local r = math.floor(h / 2) + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = w, + h = h, + -- A track has to be soft enough not to compete with the fill and solid + -- enough to show how far the fill has to go -- an invisible track makes + -- the bar a floating stub with no scale. COLOR_THEME_SECONDARY2 vanishes + -- into the panel and COLOR_THEME_DISABLED reads as a second bar, so it is + -- the disabled grey at part opacity. + color = COLOR_THEME_DISABLED, + opacity = TRACK_OPACITY, + filled = true, + rounded = r, + } + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = w, + h = h, + color = spec.color, + filled = true, + rounded = r, + -- Size closures go through luaL_checkunsigned, so this must never return + -- a negative, and never 0 either: 0 is a literal zero-pixel object rather + -- than "no width". An empty bar hides via spec.visible instead. + size = function() + local fill = math.floor(w * (spec.pct() or 0) / 100) + if fill < 1 then + fill = 1 + elseif fill > w then + fill = w + end + return fill, h + end, + visible = spec.visible, + } + return y + h +end + +--- The LED in a fixed lane at the left of a row, returning the x where text +--- starts. The lane is what everything after it measures from, so the dot's +--- radius can be tuned without the readings shifting sideways. +--- Shared with the tiers that have no header, so the LED sits at one x on +--- every tier and a widget resized between them does not appear to move its +--- indicator. +function Components.ledLane(dst, rect, y, m) + local lane = math.floor(m.sml / 2) + 2 + Components.led(dst, { + x = rect.x + math.floor(lane / 2), + -- Centred on the text's line box, not on the row. The row is the line box + -- plus padding, so centring in it drops the dot below the text it sits + -- beside -- close enough to look like a mistake rather than a choice. + y = y + math.floor(m.sml / 2), + -- Deliberately smaller than the lane. An indicator only has to be seen, and + -- at half the row height it stops reading as a dot and starts competing + -- with the text beside it. + radius = math.floor(m.sml / 4) + 1, + color = Display.ledColor, + }) + return rect.x + lane + m.pad +end + +--- The status LED. x and y are the centre, not a corner. +function Components.led(dst, spec) + dst[#dst + 1] = { + type = lvgl.CIRCLE, + x = spec.x, + y = spec.y, + radius = spec.radius, + filled = true, + color = spec.color, + } +end + +--- Two antenna dots, the active one lit. +--- The best pixels-per-bit element either reference widget has: it says which +--- RF path is carrying the link in 20px, where a text row costs a whole line. +--- The second dot hides on a single-path receiver rather than sitting dark, +--- which would imply an antenna that is not there. +--- The whole group hides on a mismatch, along with everything else the banner +--- displaces: the header is not wide enough to hold both, and dots left +--- behind are what "MODEL MISMATCH!" ends up printed across. +--- Sized off the status LED beside them rather than off the row: at row +--- height the two dots are solid blobs that take the eye before any reading +--- does, which is backwards for a qualifier. As a pair of small marks they +--- read as the same class of thing the LED is. +function Components.antenna(dst, rect, y, m) + local cell = antCell(m) + local gap = m.gap + local cx = rect.x + local r = math.floor(cell / 2) + for i = 1, 2 do + dst[#dst + 1] = { + type = lvgl.CIRCLE, + -- Centre of the mark's slot in the pair's reserved width; the footprint + -- arithmetic stays square so antX and antW need no second opinion. + x = cx + (i - 1) * (cell + gap) + r, + -- Centred on the text line rather than pinned near its top, same as the + -- status LED at the row's other end. + y = y + math.floor(m.sml / 2), + radius = r, + color = Display.antColor(i), + filled = true, + visible = (i == 2) and function() + return Display.isNotMismatch() and Display.hasDiversity() + end or Display.isNotMismatch, + } + end + return cx + 2 * cell + gap +end + +-- ============================================================================ +-- Blocks +-- ============================================================================ + +--- The header row: the status LED leading from the left, then the name and +--- the RF mode, with the antenna cells pinned to the right corner. Returns +--- the next free y. +--- The name belongs here because the header is the module row -- what it is +--- transmitting at, on which antenna -- so "ExpressLRS 250Hz" reads as a +--- header and its value, the same grammar the grid's captioned cells use. +--- spec.detail swaps the RF mode for the mode-plus-power form, for tiers with +--- no grid row left to carry the power reading. +--- A model mismatch takes the row. The binding is wrong, which is worth it -- +--- and the elements the banner displaces take the inverse visible rather than +--- being left out, so the banner lands in the same rect and nothing below it +--- moves. The LED stays: it is the blink saying the same thing. +--- Only a mismatch gets a banner. "No telemetry" is what the widget shows on +--- the bench with the quad switched off; shouting about a resting state +--- teaches the eye to ignore the banner that matters. +function Components.headerRow(dst, rect, y, m, spec) + local h = m.sml + 4 + local textX = Components.ledLane(dst, rect, y, m) + local antW = 2 * antCell(m) + m.gap + local antX = rect.x + rect.w - antW + + -- The mode's x is reserved against the widest rate name the tables carry, + -- so switching from 25Hz to K1000Full never pushes what follows sideways. + local modeW = Components.textWidth(spec.detail and "K1000Full 2000mW" or "K1000Full", SMLSIZE) + -- Wider than the header's other gaps. At one size, with only colour telling + -- the name from the reading, a tight gap leaves "ExpressLRS 250Hz" reading + -- as one run-together word. + local nameGap = m.pad * 2 + -- The dots get an "Ant" caption where the row has width to spare. Spare + -- means spare: only when the full name and the mode reserve still fit with + -- the caption in place, so a label never costs a reading its room. It hides + -- with the dots when the mismatch banner takes the row. + local antLeft = antX + local antCapW = Components.textWidth("Ant", SMLSIZE) + m.pad + if antX - antCapW - m.pad - textX - modeW - nameGap >= Components.textWidth(BRAND, SMLSIZE) then + antLeft = antX - antCapW + Components.label(dst, { + x = antLeft, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "Ant", + visible = Display.isNotMismatch, + }) + end + local brand = Components.brandText(antLeft - m.pad - textX - modeW - nameGap) + local nameX = textX + if brand then + textX = textX + Components.textWidth(brand, SMLSIZE) + nameGap + end + + -- The banner runs from wherever the readings start to the end of the row, + -- because the cells it displaces hide with it. Shortened rather than + -- squeezed: a banner is no use clipped, and "MISMATCH!" is unambiguous + -- under a name that has just said which link it is about. + local bannerLimit = rect.x + rect.w + local banner = MISMATCH + if Components.textWidth(banner, BOLD) > bannerLimit - textX then + banner = MISMATCH_SHORT + end + -- Nothing left that fits, so the name yields the row for as long as the + -- mismatch stands. Last resort, and in the other direction from everywhere + -- else here: the binding being wrong outranks even saying whose binding. + local nameHides = Components.textWidth(banner, BOLD) > bannerLimit - textX + local bannerX = textX + if nameHides then + bannerX = nameX + end + + if brand then + Components.label(dst, { + x = nameX, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + -- A constant, so it costs no closure and no per-frame string hash -- + -- except in the one case where the banner needs its width. + text = brand, + visible = nameHides and Display.isNotMismatch or nil, + }) + end + Components.label(dst, { + x = textX, + y = y, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY1, + text = spec.detail and Display.rfDetailText or Display.rfModeText, + visible = Display.isNotMismatch, + }) + Components.label(dst, { + x = bannerX, + y = y, + font = BOLD, + color = COLOR_THEME_WARNING, + -- A constant label plus a bool closure, not a formatter: statusText() + -- keeps title case for the full-screen subtitle it also feeds, and a + -- string closure would be hashed every frame to say the same thing. + text = banner, + visible = Display.isMismatch, + }) + + Components.antenna(dst, { x = antX }, y, m) + return y + h +end + +--- The hero figure: the bare LQ number in the tier's display font, with the +--- "LQ %" caption dropped to its baseline beside it. The caption's x is +--- reserved against the widest reading, so the caption never moves when the +--- number loses a digit. +--- spec.right puts a small reading on the same baseline at the right edge, +--- for a tier that merges the hero and the RSSI row into one. +function Components.hero(dst, rect, y, m, spec) + Components.label(dst, { + x = rect.x, + y = y, + font = spec.font, + color = Display.lqTextColor, + text = Display.lqHeroText, + }) + -- Small type beside a large number sits on its baseline, not its top: + -- labels position from the top, so the caption drops by the difference -- + -- four fifths of it, because line boxes carry descender room in proportion + -- to the font, and aligning box bottoms sinks the small text below the + -- shared baseline by the difference in descent. + local drop = math.max(0, math.floor((spec.h - m.sml) * 4 / 5)) + Components.label(dst, { + x = rect.x + Components.textWidth("100", spec.font) + m.pad, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "LQ %", + }) + if spec.right then + Components.label(dst, { + x = rect.x, + y = y + drop, + w = rect.w, + align = RIGHT, + font = SMLSIZE, + color = Display.detailColor, + text = spec.right, + }) + end + return y + spec.h +end + +--- A captioned value row: muted caption on the left, the reading right-aligned +--- on the same line. The bar a tier draws under it is its own call, so the row +--- reads the same with or without one. +function Components.valueRow(dst, rect, y, m, spec) + Components.label(dst, { + x = rect.x, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = spec.caption, + }) + Components.label(dst, { + x = rect.x, + y = y, + w = rect.w, + align = RIGHT, + font = SMLSIZE, + color = spec.color or COLOR_THEME_PRIMARY1, + text = spec.text, + }) + return y + m.sml +end + +--- The narrowest column that holds every cell of the grid: its widest caption +--- plus the gap plus its widest value, reserved rather than measured live so +--- the answer does not change with the readings. +local function gridMinColW(m, rows) + local need = 0 + for i = 1, #rows do + for j = 1, #rows[i] do + local cell = rows[i][j] + local cw = Components.textWidth(cell.caption, SMLSIZE) + m.pad + Components.textWidth(cell.reserve, SMLSIZE) + if cw > need then + need = cw + end + end + end + return need +end + +--- The reading grid: two equal columns of caption-value cells, in rows. +--- The captions are the sensor names EdgeTX itself puts in the model's +--- telemetry list, mixed case and all, so the grid reads straight across to +--- that list and to the module's own screen. +--- Values sit a fixed gap after their caption, left-aligned, so a reading +--- gaining a digit grows into its own column's slack and nothing reflows. +--- rows is a list of rows, each a list of { caption, text, reserve } cells -- +--- reserve being the widest string the value can render. +--- A zone too narrow for two columns lays the same cells out one per line +--- instead: a value running under its neighbour's caption reads as garbage, +--- and the tall narrow zones this happens in have the height to trade. +function Components.grid(dst, rect, y, m, rows) + local colW = math.floor((rect.w - m.pad) / 2) + if colW < gridMinColW(m, rows) then + local flat = {} + for i = 1, #rows do + for j = 1, #rows[i] do + flat[#flat + 1] = { rows[i][j] } + end + end + rows = flat + colW = rect.w + end + for i = 1, #rows do + local row = rows[i] + for j = 1, #row do + local cell = row[j] + local cx = rect.x + (j - 1) * (colW + m.pad) + Components.label(dst, { + x = cx, + y = y, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = cell.caption, + }) + Components.label(dst, { + x = cx + Components.textWidth(cell.caption, SMLSIZE) + m.pad, + y = y, + font = SMLSIZE, + color = COLOR_THEME_PRIMARY1, + text = cell.text, + }) + end + y = y + m.sml + if i < #rows then + y = y + m.gap + end + end + return y +end + +--- The downlink-and-battery grid rows the 1/1 tier shows in full and the 1/2 +--- tier appends the second of when it has the height. PWR leads because it is +--- what the module is transmitting at, where the rest is what came back from +--- the aircraft. TRSS always carries dBm: a bare -94 beside a percentage +--- invites reading it as one. +local function gridRows() + return { + { + { caption = "PWR", text = Display.powerText, reserve = "2000 mW" }, + { caption = "TQly", text = Display.tqlyText, reserve = "100 %" }, + }, + { + { caption = "BATT", text = Display.cellText, reserve = "4S 3.75 V" }, + { caption = "TRSS", text = Display.trssText, reserve = "-105 dBm" }, + }, + } +end + +--- The widest signal formatter availW carries: the diversity pair against the +--- rated floor, the active antenna against it, or the bare reading. The same +--- degradation order the compact tier walks, shared by the rows and the hero +--- that put the signal beside another reading. +local function signalFit(availW) + if availW >= Components.textWidth(SIGNAL_FULL, SMLSIZE) then + return Display.signalText + end + if availW >= Components.textWidth(SIGNAL_SHORT, SMLSIZE) then + return Display.signalShortText + end + return Display.rssiText +end + +-- ============================================================================ +-- Tiers +-- ============================================================================ + +--- The whole 1/1 tier: header, hero, the two bar rows, the reading grid. +--- Lives here rather than in each screen file because after the components +--- carry the drawing there is nothing screen-specific left in it but the +--- fonts -- and five copies of the vertical arithmetic is exactly the drift +--- the per-screen split is supposed to avoid. +--- The header hugs its hero -- a title floating a third of a card above its +--- figure reads as two cards -- and the block gaps are capped, so the content +--- reads as one column from the top and whatever height the zone has to +--- spare rests at the card's foot, the same shape the VTX Admin card beside +--- it takes. +--- LQ gets the hero and the first bar because the uplink is what you fly on; +--- the RSSI row under it keeps the rated floor in the text and draws its bar +--- against that floor, so a full LQ bar over a half-full RSSI bar still reads +--- as "perfect right now, less margin than there could be". +function Components.fullTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + + -- The grid's height depends on its shape: two columns of two rows where + -- the width holds them, one cell per line where it does not -- so the + -- shape is settled here, before the hero ladder divides what is left. + local rows = gridRows() + local gridLines = 2 + if math.floor((inner.w - m.pad) / 2) < gridMinColW(m, rows) then + gridLines = 4 + end + local gridH = m.sml * gridLines + m.gap * (gridLines - 1) + local MIN_BAR = 4 + -- Everything except the hero and the two bars. This has to match what the + -- blocks below actually consume, or the last row walks off the bottom of + -- the panel; the bar-to-RSSI air and the footer seam both bottom out at + -- m.gap, which is what the ladder budget reserves for them. + local fixedNoHero = pad * 2 + + (m.sml + 4) -- header row + + m.gap -- header to hero + + m.gap -- hero to LQ bar + + m.sml -- RSSI row + + m.gap -- RSSI row to its bar + + gridH + local heroFont, heroH = Components.heroFromLadder(m, spec.heroLadder, f.h - fixedNoHero - MIN_BAR * 2 - m.gap * 2) + + local slack = f.h - fixedNoHero - heroH + -- A gentle fraction of the slack: the tier also serves zones barely over + -- the 1/2 breakpoint, where a generous cut of a small slack makes the bars + -- thicker than the rows they sit under. The tall 1/1 still reaches the cap. + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.12), spec.barH)) + -- Capped: a block gap only has to separate its neighbours, and past a line + -- height it stops reading as a gap and starts reading as a hole. + local air = math.max(m.gap, math.min(math.floor(slack / 6), m.sml)) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + + local y = Components.headerRow(panel, inner, pad, m, {}) + y = Components.hero(panel, inner, y + m.gap, m, { font = heroFont, h = heroH }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.lqPct, + color = Display.lqBarColor, + }) + y = Components.valueRow(panel, inner, y + air, m, { + caption = "RSSI", + text = signalFit(inner.w - Components.textWidth("RSSI", SMLSIZE) - m.pad), + color = Display.detailColor, + }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + -- No rated floor means no scale, and a bar drawn against a guessed one + -- is worse than no bar. The track stays so the row keeps its height. + visible = Display.hasHeadroom, + }) + Components.grid(panel, inner, y + air, m, rows) + + lvgl.build(root) +end + +--- The 1/2 tier (a 1/3-height zone): the same card with the grid cut to what +--- fits. The header takes the power reading, the hero drops to the tier's own +--- font, and both bars survive -- they are what the widget is for. The +--- BATT/TRSS row comes back the moment the zone can hold it over bars still +--- thick enough to read, which is the rung the halfH breakpoint exists for. +function Components.halfTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + local heroH = Components.lineHeight(m, spec.heroFont) + local AIR_GAPS = 2 + -- Thinner than this and a bar is a line, not a meter. + local MIN_BAR = 3 + local fixed = pad * 2 + + (m.sml + 4) -- header row + + heroH + + m.gap -- hero to LQ bar + + m.sml -- RSSI row + + m.gap -- RSSI row to its bar + + -- The grid row is whole or absent, and it goes before either bar is + -- squeezed below the height at which it stops reading as a bar -- or when + -- the zone is too narrow for its two columns; stacked it would eat the + -- bars' height, which is backwards. That is the degradation order the + -- whole layout follows: lose a row, keep the instruments. + local gridRow = { gridRows()[2] } + local showGrid = (f.h - fixed - (m.sml + m.gap)) >= MIN_BAR * 2 + and math.floor((inner.w - m.pad) / 2) >= gridMinColW(m, gridRow) + if showGrid then + fixed = fixed + m.sml + m.gap + end + + local slack = f.h - fixed + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.28), spec.barH)) + local air = math.max(0, math.floor((slack - barH * 2) / AIR_GAPS)) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + + local y = Components.headerRow(panel, inner, pad, m, { detail = true }) + y = Components.hero(panel, inner, y + air, m, { font = spec.heroFont, h = heroH }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.lqPct, + color = Display.lqBarColor, + }) + y = Components.valueRow(panel, inner, y + air, m, { + caption = "RSSI", + text = signalFit(inner.w - Components.textWidth("RSSI", SMLSIZE) - m.pad), + color = Display.detailColor, + }) + y = Components.bar(panel, inner, y + m.gap, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, + }) + if showGrid then + Components.grid(panel, inner, y + m.gap, m, gridRow) + end + + lvgl.build(root) +end + +--- The 1/3 tier (a 1/4-height zone): header, the hero sharing its baseline +--- with the RSSI pair, one bar. +--- Losing the RSSI caption costs nothing: "-85 / -108 dBm" carries its own +--- unit, and with one bar left there is no second reading it could be +--- mistaken for. The bar is the RSSI one. Between the two, it is the one that +--- says something the number beside it does not -- it recalibrates with the +--- packet rate -- where an LQ bar plots a percentage that is already legible +--- as a percentage. +function Components.thirdTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + local heroH = Components.lineHeight(m, spec.heroFont) + local AIR_GAPS = 2 + -- Thinner than this and a bar is a line, not a meter. + local MIN_BAR = 3 + local fixed = pad * 2 + + (m.sml + 4) -- header row + + heroH + local slack = f.h - fixed + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.45), spec.barH)) + local air = math.max(0, math.floor((slack - barH) / AIR_GAPS)) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + + local y = Components.headerRow(panel, inner, pad, m, { detail = true }) + -- What the signal may take is what the hero's number and caption leave. + local rightAvail = inner.w + - Components.textWidth("100", spec.heroFont) + - Components.textWidth("LQ %", SMLSIZE) + - m.pad * 2 + y = Components.hero(panel, inner, y + air, m, { + font = spec.heroFont, + h = heroH, + right = signalFit(rightAvail), + }) + Components.bar(panel, inner, y + air, { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, + }) + + lvgl.build(root) +end + +--- The short tiers -- 1/4 and 1/6 -- as one composition: a row of readings +--- over the RSSI bar, with the bar thinning and the row shedding cells as the +--- zone shrinks. +--- One function for both because the difference between them turned out to be +--- a bar height and a cell count, both of which are measured here anyway. Two +--- builders differing only in that were two places to fix the same bug. +--- The header is what goes at this height, and the mode, power and antenna +--- cells go with it. Two rows cannot hold a header and a bar, and the bar is +--- what this widget is: below the header the choice is between a row of +--- numbers any telemetry screen could show and the one instrument only this +--- widget has. +--- The name and the mode cell read as one phrase from the left -- the same +--- grammar as the header's "ExpressLRS 250Hz", in one font and one colour -- +--- and the link readings pack the right margin: LQ, then the dBm pair at the +--- edge every taller tier gives it. +--- The LED keeps its lane, so a link that is down still says so on the tier +--- with no room to say it in words, and the hero label falls back to the +--- status text: there is no header here to carry a banner. +function Components.compactTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + local heroH = Components.lineHeight(m, spec.heroFont) + -- Thinner than this and a bar is a line, not a meter. Below it the row takes + -- the whole panel and centres in it, rather than sitting above a smear. + local MIN_BAR = 3 + local vpad = pad + local slack = f.h - (vpad * 2 + heroH) + if slack < MIN_BAR then + -- A 1/6 zone is 28px on 480x272, which is the row and its padding and + -- nothing else. So the padding gives: the left and right edges keep theirs, + -- because that is what a widget in the zone above lines its text up + -- against, and the top and bottom drop to the smallest gap the theme has. + -- Worth it for 3px of bar, because the bar is the reading that no other + -- widget on the screen offers -- and losing it here is losing it at the + -- size the widget is most often squeezed into. + vpad = m.gap + slack = f.h - (vpad * 2 + heroH) + end + -- Capped where the taller tiers' fractions land in practice, so a widget + -- resized across the ladder keeps one bar weight: a roomy 1/4 zone used to + -- fill toward this cap's double while every other size drew ~6px. + local barH = slack >= MIN_BAR and math.max(MIN_BAR, math.min(math.floor(slack * 0.3), spec.barH)) or 0 + local air = math.max(0, slack - barH) + -- Small type beside a large number sits on its baseline, not its top. + local drop = math.max(0, heroH - m.sml) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + -- With no bar the row has the panel to itself, so it centres; with one it + -- keeps the top and the bar takes the slack. + local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) + + -- The LED sits beside the dropped small text, not beside the hero, so it + -- centres on the dropped line: undropped it rides high of the reading by + -- the whole baseline offset, which at this size is plainly visible. + local textX = Components.ledLane(panel, inner, y + drop, m) + local textRight = inner.x + inner.w + local detailW = Components.textWidth("K1000Full 2000mW", SMLSIZE) + local heroW = Components.textWidth("LQ 100%", spec.heroFont) + -- The signal has its own fit ladder: the reading against the rated floor + -- where the row affords it, the bare reading where carrying the floor would + -- cost the widget its name -- a decorated half-width zone is exactly that + -- wide, and an anonymous row of numbers is worth less than one dBm figure. + local signalW = Components.textWidth(SIGNAL_SHORT, SMLSIZE) + local signalText = Display.signalShortText + local avail = textRight - textX - signalW - heroW - m.pad * 3 + if avail < Components.textWidth(BRAND_SHORT, SMLSIZE) then + local shortW = Components.textWidth("-105dBm", SMLSIZE) + local shortAvail = textRight - textX - shortW - heroW - m.pad * 3 + if shortAvail >= Components.textWidth(BRAND_SHORT, SMLSIZE) then + signalW = shortW + signalText = Display.rssiText + avail = shortAvail + end + end + local showDetail = avail >= detailW + m.pad + if showDetail then + avail = avail - detailW - m.pad + end + local brand = Components.brandText(avail) + + if brand then + -- The name leads the row, the same grammar as the header -- so the line + -- ends on a reading, not on the mark. The readings shift right by the + -- name's width and the gap is the header's name gap, wide enough that + -- name and reading do not run together. + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = brand, + }) + textX = textX + Components.textWidth(brand, SMLSIZE) + m.pad * 2 + end + if showDetail then + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.rfDetailText, + -- Hidden with the other readings while the hero carries a status. + visible = Display.isNotMismatch, + }) + textX = textX + detailW + m.pad * 2 + end + -- The reading and the status are two labels swapping over one spot, the + -- same mechanism as the header's mismatch banner. The reading keeps a + -- reserved box, right-aligned against the dBm pair; the status label is + -- content-sized -- a fixed box it could outgrow would wrap rather than + -- clip, and take the row with it -- and grows rightward across the row the + -- hidden readings have just emptied. + local heroRight = textRight - signalW - m.pad * 2 + Components.label(panel, { + x = textX, + y = y, + w = math.max(1, heroRight - textX), + align = RIGHT, + font = spec.heroFont, + text = Display.lqText, + visible = Display.noStatus, + }) + Components.label(panel, { + x = textX, + y = y, + font = spec.heroFont, + color = Display.heroColor, + text = Display.statusText, + visible = Display.hasStatus, + }) + Components.label(panel, { + x = textRight - signalW, + y = y + drop, + w = signalW, + align = RIGHT, + font = SMLSIZE, + color = Display.detailColor, + text = signalText, + visible = Display.isNotMismatch, + }) + + if barH > 0 then + -- Tucked under the row rather than sitting on the panel's floor: the bar + -- qualifies the readings above it, and a zone taller than the minimum + -- leaves its spare height under the bar, not between them. + Components.bar(panel, inner, vpad + heroH + math.min(air, m.gap * 2), { + h = barH, + pct = Display.headroomPct, + color = Display.headroomBarColor, + visible = Display.hasHeadroom, + }) + end + + lvgl.build(root) +end + +return Components diff --git a/src/WIDGETS/ELRSTelemetry/ui/display.lua b/src/WIDGETS/ELRSTelemetry/ui/display.lua new file mode 100644 index 0000000..932deeb --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -0,0 +1,440 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Display Components -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Telemetry); returns the Display table. -- +-- -- +-- Display is the read model the ui/ files consume: zero-argument -- +-- formatters passed by reference as LVGL text/color callbacks, so they -- +-- are re-evaluated every frame without a rebuild. Every entry is a dot -- +-- function for that reason -- a colon method handed to LVGL would be -- +-- called with no receiver and fail at paint time. -- +-- -- +-- This is the whole vocabulary the view has. No ui/ file reaches past -- +-- it to the CRSF transport, and none of them formats a value itself. -- +--------------------------------------------------------------------------- + +local Telemetry = ... + +local Display = {} + +-- ============================================================================ +-- Link state, as the view asks about it +-- ============================================================================ + +--- Whether the link is up. The view never reads the transport itself. +function Display.isConnected() + return Telemetry.isConnected() +end + +--- Whether to paint a mismatch warning. +function Display.isMismatch() + return Telemetry.isMismatch() +end + +--- The inverse, for the strip elements a mismatch banner displaces. They take +--- this rather than being left out, so the banner and the elements it hides +--- occupy the same rect and nothing reflows. +function Display.isNotMismatch() + return not Telemetry.isMismatch() +end + +--- Whether the RX reports a second antenna, so a layout can hide the cell +--- that would otherwise imply a path this receiver does not have. +function Display.hasDiversity() + return Telemetry.hasDiversity() +end + +-- One word per rung of Telemetry.STATUS. Title case throughout, because +-- pageSubtitle() puts these under the full-screen page's own title, where +-- shouting would read wrong. The tiers that want a caps banner spell it out +-- themselves as a fixed label. +local STATUS_TEXT = { + [Telemetry.STATUS.NO_MODULE] = "No CRSF module", + [Telemetry.STATUS.NO_TELEMETRY] = "No telemetry", + [Telemetry.STATUS.MISMATCH] = "Model Mismatch", +} + +--- Short status text when not operational or a warning is active. +--- Returns nil when connected with no warnings, which is the OK rung having +--- no entry above rather than a case handled here. +function Display.statusText() + return STATUS_TEXT[Telemetry.statusLevel()] +end + +--- Full-screen page subtitle: the same ladder, with a resting state. +function Display.pageSubtitle() + return Display.statusText() or "Telemetry" +end + +-- ============================================================================ +-- Values +-- ============================================================================ + +--- Link quality on its own, e.g. "100%", or "--" while disconnected. +function Display.lqValueText() + if not Telemetry.isConnected() then + return "--" + end + return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) +end + +--- Uplink LQ as a bar fraction, 0-100. +function Display.lqPct() + if not Telemetry.isConnected() then + return 0 + end + return Telemetry.link.rqly or 0 +end + +--- Signal headroom as a bar fraction, 0-100. Zero rather than nil so a size +--- closure never has to think about it; the bar hides on hasHeadroom(). +function Display.headroomPct() + return Telemetry.headroomPct or 0 +end + +--- Whether there is a rated floor to draw a headroom scale against. +function Display.hasHeadroom() + return Telemetry.headroomPct ~= nil +end + +--- Downlink link quality, e.g. "100 %". Captioned TQly by its callers, which +--- is the name EdgeTX puts in the model's telemetry list. +function Display.tqlyText() + local tqly = Telemetry.link.tqly + if not Telemetry.isConnected() or tqly == nil then + return "--" + end + return table.concat({ tostring(tqly), " %" }) +end + +--- Downlink RSSI, e.g. "-95 dBm". Captioned TRSS by its callers. +--- The unit is not optional: TQly sits beside this on the same row in +--- percent, and a bare -95 next to a percentage invites reading it as one. +function Display.trssText() + local trss = Telemetry.link.trss + if not Telemetry.isConnected() or trss == nil then + return "--" + end + return table.concat({ tostring(trss), " dBm" }) +end + +--- TX power on its own, e.g. "100 mW", or "--" while unknown. +function Display.powerText() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) +end + +--- Per-cell battery voltage, e.g. "4S 3.75 V", or "--". +--- The pack total is a separate value so a row can carry one or both. +function Display.cellText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + local cells = Telemetry.cellCnt + if cells == nil then + return "--" + end + return string.format("%dS %.2f V", cells, vbat / cells) +end + +--- Pack voltage, e.g. "15.20 V", or "--". +function Display.packText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + return string.format("%.2f V", vbat) +end + +--- Link quality as the hero label spells it, e.g. "LQ 100%". +function Display.lqText() + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) +end + +--- The bare LQ number for the hero figure, e.g. "99", or "--" while +--- disconnected. No caption and no unit: the tier draws those as its own +--- fixed label, so the number can take a display font without dragging a +--- "%" the same size along. +function Display.lqHeroText() + if not Telemetry.isConnected() then + return "--" + end + return tostring(Telemetry.link.rqly or 0) +end + +--- Active antenna RSSI, e.g. "-87dBm", or "" while unknown. +--- sep is the gap between the number and the unit: "" in tight minimized +--- layouts, " " where the full-screen rows have room for it. +function Display.rssiText(sep) + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + return table.concat({ tostring(rssi), sep or "", "dBm" }) +end + +--- Whether a status is standing, for the labels that swap places with the +--- readings: the status label takes hasStatus, the reading it displaces takes +--- noStatus, and the pair occupy the same rect so nothing reflows. +function Display.hasStatus() + return Display.statusText() ~= nil +end + +function Display.noStatus() + return Display.statusText() == nil +end + +--- RSSI against the rate's rated floor, e.g. "-90 / -112 dBm". +--- The pair is the point: RSSI alone says nothing until you know what the +--- receiver can still hear at, and that figure moves with the packet rate. +--- On diversity hardware both antennas appear ("-85 -92 / -112 dBm") in +--- fixed 1-2 order, so neither number jumps position when the RX switches +--- paths. Drops the floor when the rate is unrated. +function Display.signalText() + if not Telemetry.isConnected() then + return "" + end + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + local parts + if Telemetry.hasDiversity() then + parts = { tostring(Telemetry.link.rssi1 or "--"), " ", tostring(Telemetry.link.rssi2) } + else + parts = { tostring(rssi) } + end + local sens = Telemetry.link.sens + if sens ~= nil then + parts[#parts + 1] = " / " + parts[#parts + 1] = tostring(sens) + end + parts[#parts + 1] = " dBm" + return table.concat(parts) +end + +--- The signal pair in its narrowest form: the active antenna alone against +--- the floor. The compact tier reserves a fixed box for this reading, and the +--- diversity pair is wider than the box can be without pushing the hero into +--- wrapping -- so the tightest tier shows the path the link is on, the same +--- trade its width ladder already makes with the antenna cells. +function Display.signalShortText() + if not Telemetry.isConnected() then + return "" + end + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + local sens = Telemetry.link.sens + if sens == nil then + return table.concat({ tostring(rssi), " dBm" }) + end + return table.concat({ tostring(rssi), " / ", tostring(sens), " dBm" }) +end + +--- RF mode text, e.g. "250Hz". Narrow zones use this without the power suffix. +function Display.rfModeText() + if not Telemetry.isConnected() then + return "" + end + return Telemetry.rfModeName() or "" +end + +--- RF mode + TX power text, e.g. "250Hz 50mW". +function Display.rfDetailText() + local parts = { Display.rfModeText() } + local tpwr = Telemetry.link.tpwr + if Telemetry.isConnected() and tpwr then + parts[#parts + 1] = table.concat({ tostring(tpwr), "mW" }) + end + return table.concat(parts, " ") +end + +--- Battery text for the full-screen row, e.g. "4S 3.80V (15.20V)". +function Display.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 + +-- ============================================================================ +-- Colours and fonts +-- ============================================================================ + +--- Hero label colour: red only while a connected link reports a mismatch. +function Display.heroColor() + if Telemetry.isMismatch() then + return COLOR_THEME_WARNING + end + return COLOR_THEME_PRIMARY1 +end + +-- One health ramp, shared by the bars and the status LED, so a green bar and +-- an amber dot can never describe the same link. +-- +-- Theme colours rather than the GREEN/ORANGE/RED literals. Those are raw +-- primaries -- GREEN is RGB(0,255,0) and RED is RGB(255,0,0) +-- (colors.cpp:51-54) -- which is why a full-width LQ bar in GREEN was the +-- loudest thing on the screen, and they stay that way whatever theme the +-- pilot picked. The theme's own EDIT/ACTIVE/WARNING are the same three +-- meanings in colours chosen to sit together, and they repaint with the theme. +local HEALTH = { COLOR_THEME_EDIT, COLOR_THEME_ACTIVE, COLOR_THEME_WARNING } + +-- The same ramp for text. COLOR_THEME_ACTIVE is a bright yellow: fine as a +-- bar fill, illegible as a word on the light themes' near-white panel, so +-- warnings in text keep ORANGE. +local HEALTH_TEXT = { COLOR_THEME_EDIT, ORANGE, COLOR_THEME_WARNING } + +-- Link margin in dB above the rated floor. At or below MARGIN_CRIT the +-- receiver is at the edge of what it can hear; above MARGIN_WARN there is +-- room left to fly into. Same thresholds the range percentage used, the right +-- way up and in the unit the number is actually measured in. +local MARGIN_CRIT = 10 +local MARGIN_WARN = 30 + +--- Fill colour for a health level: 1 good, 2 warn, 3 critical. +function Display.healthColor(level) + return HEALTH[level] or COLOR_THEME_DISABLED +end + +--- The same, for text that has to stay readable on the panel. +function Display.healthTextColor(level) + return HEALTH_TEXT[level] or COLOR_THEME_DISABLED +end + +-- Uplink LQ. Below LQ_CRIT ExpressLRS is dropping enough packets to matter; +-- at or above LQ_GOOD the link is doing what it is supposed to. +local LQ_GOOD = 90 +local LQ_CRIT = 50 + +--- Health level of the uplink LQ. +function Display.lqLevel() + if not Telemetry.isConnected() then + return 3 + end + local lq = Telemetry.link.rqly or 0 + if lq >= LQ_GOOD then + return 1 + end + if lq >= LQ_CRIT then + return 2 + end + return 3 +end + +--- Health level of the link margin, worst while there is no margin to judge. +function Display.marginLevel() + local db = Telemetry.marginDb() + if db == nil then + return 3 + end + if db > MARGIN_WARN then + return 1 + end + if db > MARGIN_CRIT then + return 2 + end + return 3 +end + +--- Fill colour for the LQ bar. +function Display.lqBarColor() + return Display.healthColor(Display.lqLevel()) +end + +--- Colour for the LQ headline, which is a word and not a fill. +function Display.lqTextColor() + return Display.healthTextColor(Display.lqLevel()) +end + +--- Fill colour for the headroom bar. +function Display.headroomBarColor() + return Display.healthColor(Display.marginLevel()) +end + +-- Blink periods in getTime() ticks, which run at 10 ms. Halved for the on/off +-- phase, so BLINK_SLOW is 1 Hz and BLINK_FAST is 4 Hz. +local BLINK_SLOW = 50 +local BLINK_FAST = 12 + +--- Whether a blink of the given half-period is in its lit phase. +local function lit(halfPeriod) + return math.floor(getTime() / halfPeriod) % 2 == 0 +end + +--- Status LED colour. +--- Red means bad and blinking means the link is broken or bound to the wrong +--- model; solid means the link works and only the numbers are poor. The +--- reference widgets blink to mean "healthy", which teaches the eye to ignore +--- a blinking dot -- the one thing it must not do here. +--- The off phase returns the track colour rather than hiding the dot, so it +--- reads as an LED that is off instead of a hole in the strip, and stays right +--- under a transparent background. +function Display.ledColor() + local level = Telemetry.statusLevel() + local STATUS = Telemetry.STATUS + if level == STATUS.NO_MODULE then + return COLOR_THEME_DISABLED + end + if level == STATUS.NO_TELEMETRY then + return lit(BLINK_SLOW) and COLOR_THEME_WARNING or COLOR_THEME_DISABLED + end + if level == STATUS.MISMATCH then + return lit(BLINK_FAST) and COLOR_THEME_WARNING or COLOR_THEME_DISABLED + end + return Display.lqBarColor() +end + +--- Colour for antenna cell n (1 or 2): lit when that path is the active one. +--- A factory, not a callback -- call it when building. +function Display.antColor(n) + return function() + if not Telemetry.isConnected() then + return COLOR_THEME_DISABLED + end + -- ANT is 0-based and the cells are 1-based, same convention as the + -- full-screen page's "Ant 1" / "Ant 2" rows. + if (Telemetry.link.ant or 0) + 1 == n then + -- The theme's accent, not COLOR_THEME_PRIMARY1: that is the text colour, + -- RGB(0,0,0) on the light themes, and a filled black block is not text. + -- This cell means "the link is on this path", which is what the accent + -- says everywhere else in EdgeTX. + return COLOR_THEME_FOCUS + end + return COLOR_THEME_DISABLED + end +end + +--- Detail line colour: warns as the link margin shrinks, neutral while there +--- is no margin to judge -- no link, or a rate with no published floor. +--- Deliberately not the health ramp: a line of text that turns green whenever +--- nothing is wrong is noise, while a bar that turns green is the point of +--- drawing it. +function Display.detailColor() + local db = Telemetry.marginDb() + if db == nil then + return COLOR_THEME_SECONDARY1 + end + local level = Display.marginLevel() + if level == 1 then + return COLOR_THEME_SECONDARY1 + end + return Display.healthTextColor(level) +end + +-- ============================================================================ +-- Return components +-- ============================================================================ + +return Display diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua new file mode 100644 index 0000000..67935d3 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -0,0 +1,306 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Full-Screen Page -- +-- Loaded via loadScript() from ELRSTelemetry/loadable.lua with -- +-- (Telemetry, Display); returns the FullScreenUI table. -- +-- -- +-- One layout for every screen size, because lvgl.page() handles the -- +-- responsive part: the link, power, flight controller and GPS rows, and -- +-- the no-module checklist in place of all of them. -- +-- -- +-- Loaded lazily, on the first full-screen entry. Only one widget can be -- +-- full screen at a time, so eager loading would leave this resident in -- +-- every other instance for nothing. -- +-- -- +-- build() runs on entry, not per frame: EdgeTX calls a widget's -- +-- update() when it is constructed, when it enters or leaves full -- +-- screen, and when its options are edited. Every value here is a -- +-- per-frame text or color callback, so nothing on the page needs a -- +-- rebuild to stay current. -- +--------------------------------------------------------------------------- + +local Telemetry, Display = ... + +local FullScreenUI = {} + +-- ============================================================================ +-- Row helpers +-- ============================================================================ + +-- Portrait screens get a narrower label column to leave more room for values. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +--- Wrap a value formatter so a row reads "--" while the link is down. +--- Only the link and power rows take this. The flight controller and GPS rows +--- have their own fallbacks, and latitude/longitude deliberately keep showing +--- the last known position after the link drops -- that is what you read when +--- you are looking for a model that stopped answering. +local function whenConnected(fn) + return function() + if not Telemetry.isConnected() then + return "--" + end + return fn() + end +end + +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 + +-- ============================================================================ +-- The page +-- ============================================================================ + +function FullScreenUI.build() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = Display.pageSubtitle, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- No module — show checklist instead of telemetry. Decided at build time on + -- purpose: module presence is a Model Setup fact, and changing it means + -- leaving this page, which rebuilds it on the way back in. + 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 = COLOR_THEME_WARNING, + text = "Model Mismatch — RC commands not sent", + visible = Display.isMismatch, + }, + }) + + -- Link Status section + createSectionHeader(fields, "Link Status") + + createDisplayRow(fields, "RF Mode", Display.rfModeText) + + createDisplayRow(fields, "Link Quality", Display.lqValueText) + + createDisplayRow( + fields, + "RSSI 1", + whenConnected(function() + local rssi1 = Telemetry.link.rssi1 + if rssi1 == nil then + return "--" + end + return table.concat({ tostring(rssi1), " dBm" }) + end) + ) + + createDisplayRow( + fields, + "RSSI 2", + whenConnected(function() + 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", + whenConnected(function() + 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) + ) + + -- Sensitivity and Link Margin sit directly under the RSSI rows and share + -- their unit, so the arithmetic between the three is visible: this is what + -- the receiver is rated to hear, and this is how far above it you are. + createDisplayRow( + fields, + "Sensitivity", + whenConnected(function() + local sens = Telemetry.link.sens + if sens == nil then + return "--" + end + return table.concat({ tostring(sens), " dBm @ ", Display.rfModeText() }) + end) + ) + + createDisplayRow( + fields, + "Link Margin", + whenConnected(function() + local db = Telemetry.marginDb() + if db == nil then + return "--" + end + return string.format("%+d dB", db) + end), + Display.detailColor + ) + + -- Power section + createSectionHeader(fields, "Power") + + createDisplayRow( + fields, + "TX Power", + whenConnected(function() + local tpwr = Telemetry.link.tpwr + if tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) + end) + ) + + -- Flight Controller section + createSectionHeader(fields, "Flight Controller") + + createDisplayRow(fields, "Battery", Display.batteryTextVerbose) + + createDisplayRow(fields, "Current", function() + local curr = Telemetry.link.curr + if curr == nil or curr <= 0 then + return "--" + end + return string.format("%.2f A", curr) + end) + + createDisplayRow(fields, "Flight Mode", function() + local fm = Telemetry.link.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 = Telemetry.link.sats + if sats == nil then + return "--" + end + return tostring(sats) + end) + + createDisplayRow(fields, "Speed", function() + local gspd = Telemetry.link.gspd + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) + + createDisplayRow(fields, "Altitude", function() + local alt = Telemetry.link.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 + +return FullScreenUI diff --git a/src/WIDGETS/ELRSTelemetry/ui/hd.lua b/src/WIDGETS/ELRSTelemetry/ui/hd.lua new file mode 100644 index 0000000..18cb632 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -0,0 +1,112 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 800x480 (HD) -- +-- High definition landscape (TX16S Mark 3) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display +local bgOpacity = ctx.bgOpacity +local Components = ctx.Components + +local WidgetUI = {} + +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints: absolute pixel values for 800x480. +WidgetUI.breakpoints = { + topBarW = 200, + quarterH = 104, + thirdH = 146, + halfH = 154, +} + +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 209px half up to the 418px full, and the builder takes the largest +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card -- and +-- this screen's fonts are large enough that BOLD never earns a rung. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = MIDSIZE }, + half = { hero = MIDSIZE }, + full = { heroLadder = { DBLSIZE, MIDSIZE } }, +} + +-- The cap on bar thickness, in this screen's pixels. +local BAR_H = 8 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + }) +end + +--- 1/3: header, the hero beside the RSSI pair, one bar. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- 1/2: the card without the full grid. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this hands it the ladder the screen can afford. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +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.quarterH then + WidgetUI.buildCompact(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..d95ada5 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -0,0 +1,98 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display +local bgOpacity = ctx.bgOpacity +local Components = ctx.Components + +local WidgetUI = {} + +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints: absolute pixel values for 320x480 portrait. +-- No half rung: portrait zones are tall, so everything past the third +-- breakpoint has the height for the whole card and goes straight to full. +WidgetUI.breakpoints = { + topBarW = 80, + quarterH = 78, + thirdH = 110, +} + +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 145px third up to the 435px full. The ladder tops out at DBLSIZE: +-- the screen is narrow, and XXL digits crowd the caption and the readings +-- beside them off a 320-wide card. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- The cap on bar thickness, in this screen's pixels. +local BAR_H = 5 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + }) +end + +--- 1/3: header, the hero beside the RSSI pair, one bar. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this hands it the ladder the screen can afford. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +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.quarterH then + WidgetUI.buildCompact(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..161291c --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -0,0 +1,111 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x272 (SD) -- +-- Standard definition landscape (TX16S, TX16S MAX, TX16S Mark II) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display +local bgOpacity = ctx.bgOpacity +local Components = ctx.Components + +local WidgetUI = {} + +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints: absolute pixel values for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + quarterH = 52, + thirdH = 73, + halfH = 108, +} + +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 113px half up to the 227px full, and the builder takes the largest +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- The cap on bar thickness, in this screen's pixels. +local BAR_H = 6 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + }) +end + +--- 1/3: header, the hero beside the RSSI pair, one bar. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- 1/2: the card without the full grid. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this hands it the ladder the screen can afford. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +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.quarterH then + WidgetUI.buildCompact(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..fcfebc8 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -0,0 +1,112 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 480x320 (SD Tall) -- +-- Jumper T15, T15 Pro, TX15, ST16, PL18 -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display +local bgOpacity = ctx.bgOpacity +local Components = ctx.Components + +local WidgetUI = {} + +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints: absolute pixel values for 480x320. +-- 48px taller than 480x272 so widget zones are proportionally taller. +WidgetUI.breakpoints = { + topBarW = 100, + quarterH = 62, + thirdH = 86, + halfH = 108, +} + +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 137px half up to the 275px full, and the builder takes the largest +-- font the zone's height budget affords. It tops out at DBLSIZE -- the +-- display faces above it shout down every other reading on the card. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- The cap on bar thickness, in this screen's pixels. +local BAR_H = 6 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + }) +end + +--- 1/3: header, the hero beside the RSSI pair, one bar. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- 1/2: the card without the full grid. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this hands it the ladder the screen can afford. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +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.quarterH then + WidgetUI.buildCompact(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..af4c0f2 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -0,0 +1,111 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - UI for 320x240 (Small) -- +-- Small color LCD (PA01) -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display +local bgOpacity = ctx.bgOpacity +local Components = ctx.Components + +local WidgetUI = {} + +-- Font line heights, measured once. Fonts do not change under the widget, so +-- there is nothing to invalidate; this only avoids re-measuring per build. +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints: absolute pixel values for 320x240. +-- Smallest color screen — everything is compact. +WidgetUI.breakpoints = { + topBarW = 80, + quarterH = 42, + thirdH = 58, + halfH = 82, +} + +-- The full tier's hero is a ladder, not a size: the tier serves every zone +-- from the 102px half up to the 204px full. The ladder tops out at MIDSIZE: +-- the display faces beyond it swallow this screen's card whole. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { MIDSIZE, BOLD } }, +} + +-- The cap on bar thickness, in this screen's pixels. +local BAR_H = 5 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6: one row of readings over the RSSI bar. The composition is +--- shared, and it sheds cells and bar height as the zone shrinks. +function WidgetUI.buildCompact(w, h, opa) + local m = measured() + Components.compactTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.compact.hero, + barH = BAR_H, + }) +end + +--- 1/3: header, the hero beside the RSSI pair, one bar. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- 1/2: the card without the full grid. +--- Both bars survive, which is the whole point of the layout: this is the +--- size the widget is usually placed at. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- 1/1: the whole card -- header, hero, both bar rows, the reading grid. +--- Everything but the fonts is shared, so the composition itself lives in +--- ui/components.lua and this hands it the ladder the screen can afford. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +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.quarterH then + WidgetUI.buildCompact(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..52ad92f --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -0,0 +1,74 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Shared Top Bar UI -- +-- Loaded via loadScript() from each per-screen ui/ file with -- +-- ({ Display }); returns the TopBarUI table. -- +-- -- +-- Two stacked lines and no background, so it composes onto the dark -- +-- header. Everything it shows is abbreviated to fit a top-bar slot, -- +-- which is why it spells out its own text ladder rather than reusing -- +-- the hero label's. -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display + +local TopBarUI = {} + +--- The top bar sits on the dark header, so it needs PRIMARY2 where +--- Display.heroColor uses PRIMARY1. +local function mismatchColor() + if Display.isMismatch() then + return COLOR_THEME_WARNING + 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 Display.isConnected() then + return "--" + end + if Display.isMismatch() then + return "Model" + end + return Display.lqText() + end, + }, + { + type = lvgl.LABEL, + align = CENTER, + font = SMLSIZE, + color = mismatchColor, + text = function() + if not Display.isConnected() then + return "--" + end + if Display.isMismatch() then + return "Mismatch" + end + return Display.rssiText() + end, + }, + }, + }, + }) +end + +return TopBarUI diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua new file mode 100644 index 0000000..100272e --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -0,0 +1,609 @@ +--------------------------------------------------------------------------- +-- 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 -- ui/display.lua, ui/fullscreen.lua -- +-- 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, except PresetsStorage: -- +-- main.lua hands every instance the same store, whose latch table is -- +-- how the 6POS automation consumes an edge exactly once per radio. -- +--------------------------------------------------------------------------- + +local zone, options, crsf, CRSFSession, PresetsStorage = ... + +-- ============================================================================ +-- 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 debounce window. The processing state itself -- consumed position, + -- collection, push trigger level -- lives on PresetsStorage.latch, shared + -- across widget instances. + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- 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, +}) + +-- ============================================================================ +-- 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 when the consumed (collection, position) pair changes. The state +--- lives on the shared latch so an edge produces one write per radio, not one +--- per instance: widget callbacks run sequentially in one Lua state, so the +--- first instance that can act consumes the edge and the rest see none. +local function process6Pos() + if not PresetsStorage.enabled then + return + end + if PresetsStorage.source == 0 then + return + end + + local value = getValue(PresetsStorage.source) + if value == nil then + return + end + + local latch = PresetsStorage.latch + local pos = mapTo6Pos(value) + local now = getTime() + + -- Debounce: require stable position for DEBOUNCE ticks. Shared: the source + -- is radio state, so one debounce serves every instance. + if pos ~= latch.stablePos then + latch.stablePos = pos + latch.stableTime = now + return + end + if now - latch.stableTime < VTXAdmin.DEBOUNCE then + return + end + + -- Only consume a position once THIS instance can write. writeConfig() drops + -- everything outside the ready phase, and the latch is taken before it is + -- called, so latching any earlier discards the edge permanently -- for every + -- instance at once. 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: send when either half of the (collection, position) pair + -- the module is holding changes. Picking a different collection retunes the + -- VTX without touching the switch. + if pos == latch.lastPos and PresetsStorage.collection == latch.lastCollection then + return + end + latch.lastPos = pos + latch.lastCollection = PresetsStorage.collection + + -- An unused slot leaves the VTX untouched rather than turning it off. The + -- latch is already taken, so this is deliberate: the skip does not retry. + local preset = PresetsStorage.items[pos] + if not preset or preset.band == 0 then + return + end + + VTXAdmin.applyPreset(preset.band, preset.channel) + if PresetsStorage.autoPushVtx then + VTXAdmin.pushToVtx() + end +end + +--- Runs every tick. Edge-detects the pushSource going high and triggers +--- pushToVtx() to send the current config to the VTX. The state lives on the +--- shared latch so a rising edge fires one push per radio, not one per +--- instance. +local function processPushTrigger() + if PresetsStorage.autoPushVtx then + return + end + if PresetsStorage.pushSource == 0 then + return + end + + local val = getValue(PresetsStorage.pushSource) + if val == nil then + return + end + + local latch = PresetsStorage.latch + local high = val > 0 + + -- The level latch is only meaningful for the source it was sampled from: a + -- reassignment adopts the new source's level without firing. A source that + -- is already high was not just moved there by the user. This also seeds the + -- latch on the first sample after boot. + if PresetsStorage.pushSource ~= latch.pushSourceSeen then + latch.pushSourceSeen = PresetsStorage.pushSource + latch.pushLastHigh = high + return + end + + -- Only consume once THIS instance can send (the same gate pushToVtx + -- applies): latching earlier would eat the rising edge for every instance + -- while nobody could act on it. + if not VTXAdmin.isReady() and not VTXAdmin.isSending() then + return + end + + local wasHigh = latch.pushLastHigh + latch.pushLastHigh = high + + -- Edge detection: trigger only on rising edge (low -> high) + if high and not wasHigh then + 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) + +-- ============================================================================ +-- 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 wgt = { + zone = zone, + options = options, +} + +function wgt.background() + session:drain() + VTXAdmin.tick() +end + +function wgt.refresh(_event, _touchState) + wgt.background() +end + +local FullScreenUI + +--- Build the full-screen page, loading it the first time it is asked for. +--- Only one widget can be full screen at a time, so loading it eagerly would +--- leave a page builder resident in every instance that never shows one -- +--- and this widget is the one users place many of. update() is not a hot +--- path, so a loadScript here costs nothing. +--- +--- The page is built once, on entry. Everything it shows updates in place from +--- there: every value is a per-frame callback or a control that polls its +--- get() -- see the ui/fullscreen.lua header for the constraint that keeps +--- that true. +local function buildFullScreen() + if not FullScreenUI then + FullScreenUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua")(VTXAdmin, PresetsStorage) + end + FullScreenUI.build() +end + +function wgt.update(newOptions) + wgt.options = newOptions + if lvgl.isFullScreen() then + if VTXAdmin.isReady() then + VTXAdmin.syncDesiredFromState() + end + buildFullScreen() + else + WidgetUI.build(wgt.zone, wgt.options) + end +end + +-- Initial build +WidgetUI.build(wgt.zone, wgt.options) + +return wgt diff --git a/src/WIDGETS/ELRSVTXAdmin/main.lua b/src/WIDGETS/ELRSVTXAdmin/main.lua new file mode 100644 index 0000000..bfae839 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -0,0 +1,62 @@ +--------------------------------------------------------------------------- +-- 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 + if not _vtxPresetsStorage then + local getFileStorage = loadScript("/SCRIPTS/ELRS/file_storage.lua") + ---@diagnostic disable-next-line: need-check-nil + local fileStorage = getFileStorage() + local getPresetsStorage = loadScript("/WIDGETS/ELRSVTXAdmin/presets_storage.lua") + ---@diagnostic disable-next-line: need-check-nil + _vtxPresetsStorage = getPresetsStorage(fileStorage) + end + local loadable = loadScript("/WIDGETS/" .. name .. "/loadable.lua") + ---@diagnostic disable-next-line: need-check-nil + return loadable(zone, options, _crsfSingleton, _crsfSessionClass, _vtxPresetsStorage) +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..b0eb390 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua @@ -0,0 +1,176 @@ +--------------------------------------------------------------------------- +-- 6POS Preset Storage -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua with (FileStorage) -- +-- and shared by every widget instance; returns the PresetsStorage table.-- +-- -- +-- Settings store: six preset collections, the active collection, the -- +-- sources and flags, and their persistence as a key=value file -- plus -- +-- the radio-wide automation latches, which live here because an edge -- +-- must be consumed exactly once per radio. 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" + +-- Six collections of six slots, one slot per 6POS switch position. +local COLLECTION_COUNT = 6 +local SLOT_COUNT = 6 + +-- The file layout, declared once: FileStorage writes these keys in this order. +-- Scalars first, then one line per collection -- "band,channel" pairs joined +-- with ";". Worst case ~225 bytes, well inside FileStorage's bounded 512-byte +-- read; outgrowing that cap would truncate silently and the next save would +-- write defaults over the tail collections, so keep the budget in mind here. +local SAVE_KEYS = { "enabled", "source", "autoPushVtx", "pushSource", "collection" } +local COLLECTION_KEYS = {} +for c = 1, COLLECTION_COUNT do + COLLECTION_KEYS[c] = table.concat({ "c", c }) + SAVE_KEYS[#SAVE_KEYS + 1] = COLLECTION_KEYS[c] +end + +local PresetsStorage = { + -- Exposed for the editor's collection dropdown, so the count has one home. + COLLECTION_COUNT = COLLECTION_COUNT, + + -- collections[c][i] = { band, channel }, c and i both 1..6. + collections = {}, + + -- The active collection's slots: an alias of collections[collection], the + -- same table and never a copy, so writes through items land in the + -- collection. Every reader indexes PresetsStorage.items on each access and + -- none holds it across calls, so re-pointing it here switches the 6POS + -- automation, the cheatsheet and the editor rows at once. + items = {}, + + -- Active collection, 1..6. Selects both what the 6POS switch applies and + -- what the full-screen preset rows edit -- one selector, no second mode. + collection = 1, + + -- Radio-wide automation latches, never persisted. They live on the shared + -- store rather than on a widget instance because an edge -- a 6POS movement, + -- a collection change, a push trigger -- must be consumed exactly once per + -- radio: the first instance whose session can act consumes it, and the rest + -- see no edge. + latch = { + lastPos = -1, -- last consumed 6POS position (cheatsheet highlight reads this) + lastCollection = -1, -- the collection lastPos was resolved through + stablePos = -1, -- debounce candidate + stableTime = 0, + ---@type boolean? push trigger level; nil until the source adopt seeds it + pushLastHigh = nil, + pushSourceSeen = 0, -- the source pushLastHigh was sampled from + }, + + 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 + +--- Split a "band,channel;band,channel;..." collection line into up to +--- SLOT_COUNT slots, using plain string.find (no regex). Invalid or missing +--- segments stay nil so the caller fills defaults per slot. +local function splitSlots(val) + local slots = {} + local pos = 1 + for i = 1, SLOT_COUNT do + local semi = string.find(val, ";", pos, true) + local segment + if semi then + segment = string.sub(val, pos, semi - 1) + pos = semi + 1 + else + segment = string.sub(val, pos) + pos = #val + 1 + end + local band, channel = splitBandChannel(segment) + if band and channel then + slots[i] = { band = band, channel = channel } + end + end + return slots +end + +--- Schema: enabled/autoPushVtx are "1"/"0" booleans, source/pushSource are +--- source IDs, collection is the active index 1..6, and c1..c6 are collection +--- lines of "band,channel" pairs joined with ";", slots defaulting to +--- Raceband R1..R6. +function PresetsStorage.load() + local kv = FileStorage.read(PATH) or {} + PresetsStorage.enabled = (kv.enabled == "1") + PresetsStorage.source = tonumber(kv.source) or 0 + PresetsStorage.autoPushVtx = (kv.autoPushVtx == "1") + PresetsStorage.pushSource = tonumber(kv.pushSource) or 0 + + local collections = {} + for c = 1, COLLECTION_COUNT do + local slots = splitSlots(kv[COLLECTION_KEYS[c]] or "") + -- Fill missing slots with Raceband defaults (R1..R6) + for i = 1, SLOT_COUNT do + if not slots[i] then + slots[i] = { band = 5, channel = i } + end + end + collections[c] = slots + end + PresetsStorage.collections = collections + + local collection = tonumber(kv.collection) or 1 + if collection < 1 or collection > COLLECTION_COUNT then + collection = 1 + end + PresetsStorage.collection = collection + PresetsStorage.items = collections[collection] +end + +function PresetsStorage.save() + local values = { + enabled = PresetsStorage.enabled and "1" or "0", + source = PresetsStorage.source, + autoPushVtx = PresetsStorage.autoPushVtx and "1" or "0", + pushSource = PresetsStorage.pushSource, + collection = PresetsStorage.collection, + } + for c = 1, COLLECTION_COUNT do + local slots = PresetsStorage.collections[c] + local parts = {} + for i = 1, SLOT_COUNT do + parts[i] = table.concat({ slots[i].band, ",", slots[i].channel }) + end + values[COLLECTION_KEYS[c]] = table.concat(parts, ";") + end + FileStorage.write(PATH, SAVE_KEYS, values) +end + +--- Make a collection active and persist the choice. A single assignment, never +--- an in-place rebuild: display.lua and fullscreen.lua index items[i] from +--- LVGL callbacks that can land on any frame, and a half-built table there is +--- a nil index -- which makes the build fail silently and the whole UI vanish. +function PresetsStorage.selectCollection(c) + if c < 1 or c > COLLECTION_COUNT then + return + end + if c == PresetsStorage.collection then + return + end + PresetsStorage.collection = c + PresetsStorage.items = PresetsStorage.collections[c] + PresetsStorage.save() +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..85207de --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -0,0 +1,390 @@ +--------------------------------------------------------------------------- +-- 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, plus the card's composite rows. WidgetLayout builds the -- +-- minimized zone containers. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage = ... + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +--- pad overrides the container's border padding, for a tier where the default +--- costs more height than it has. Pass a table to keep the horizontal padding +--- while trimming the vertical: the left edge is what a widget in the zone +--- above or beside lines its own text up against, so it is not the one to give. +function WidgetLayout.column(w, h, opa, children, pad) + 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 = pad or 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 + +--- The hero and the active preset cell take the theme's accent -- the reading +--- is what the widget exists to show, and the accent is what "the current one" +--- looks like everywhere else in EdgeTX. A confirmed pit mode outranks it: +--- red on the reading itself is the one signal worth recolouring the hero for. +--- An aux binding is not an assertion, so it stays on the accent. +function VTXDisplay.heroColor() + if VTXAdmin.state.pitmode then + return RED + end + return COLOR_THEME_FOCUS +end + +--- Pit mode as the header's right end spells it. "" when no power is set: +--- ExpressLRS cannot send pit mode without it, and the firmware hides the +--- field. A switch binding names the switch rather than asserting a position +--- the folder name does not carry. +function VTXDisplay.pitHeaderText() + if not VTXAdmin.hasPower() then + return "" + end + if VTXAdmin.state.pitmode then + return "PIT ON" + end + if VTXAdmin.state.pitmodeAux then + return table.concat({ "PIT ", VTXAdmin.state.pitmodeAux }) + end + return "PIT OFF" +end + +--- Red only when pit mode is confirmed on. +function VTXDisplay.pitHeaderColor() + return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +--- The power level in the terse form that rides beside the channel on the +--- one-line tiers, e.g. "P2". "" when no power is set: ExpressLRS cannot +--- report one, and a bare "P" would read as a reading stuck mid-arrival. +function VTXDisplay.powerShort() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "P", VTXAdmin.state.power }) +end + +--- The same reading said in full, e.g. "Power 2", for the hero row -- at that +--- size the terse form reads as part of the channel rather than as its own +--- reading. +function VTXDisplay.powerLong() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "Power ", VTXAdmin.state.power }) +end + +--- The header row: the widget's name in the muted caption colour on the left, +--- the pit state pinned to the right edge of the same line. +--- Not a flex row: a right-aligned label needs an explicit width to align +--- inside, so the two labels sit in a plain box at absolute x and the hidden +--- pit label cannot reflow the name. +--- w is the zone width; the box subtracts the container's own padding so the +--- right edge lands where every other row's content ends. +function VTXDisplay.buildHeader(w) + local cw = w - 2 * lvgl.PAD_SMALL + return { + type = lvgl.BOX, + w = cw, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + x = 0, + y = 0, + w = cw, + align = RIGHT, + font = SMLSIZE, + color = VTXDisplay.pitHeaderColor, + text = VTXDisplay.pitHeaderText, + visible = VTXDisplay.showChannel, + }, + }, + } +end + +--- The hero pair for a flex column: the status while the VTX is quiet, and +--- once it is tuned the band and channel in the tier's display font with +--- "Power 2" sat on its baseline beside it. Two children to append in order +--- -- a hidden flex child costs no height, so only the live one takes a row. +--- The status takes the same display font as the reading it stands in for: +--- the two swap over one row, and at two sizes everything below them jumps +--- the moment the VTX loads. +--- A plain box with absolute children rather than a flex row: small type +--- beside a large number sits on its baseline, not its top, and flex has no +--- way to say that. Same mechanics as the telemetry hero's caption -- the +--- power's x is reserved against the widest reading, so it never moves when +--- the channel changes. +function VTXDisplay.buildHero(font) + local heroH = select(2, lcd.sizeText("0", font)) + -- Four fifths of the height difference, not all of it: line boxes carry + -- descender room in proportion to the font, so aligning box bottoms sinks + -- the small text below the shared baseline by the difference in descent. + local drop = math.max(0, math.floor((heroH - select(2, lcd.sizeText("0", SMLSIZE))) * 4 / 5)) + return { + type = lvgl.LABEL, + align = LEFT, + font = font, + -- The muted theme colour, not the text primary: at the hero size a black + -- "Loading..." reads as the reading itself, and a status is background. + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + }, { + type = lvgl.BOX, + h = heroH, + visible = VTXDisplay.showChannel, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = font, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + }, + { + type = lvgl.LABEL, + x = (lcd.sizeText("R8", font)) + lvgl.PAD_MEDIUM, + y = drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerLong, + }, + }, + } +end + +--- The 6POS presets as a row of six fixed-width cells, the latched one lit in +--- the accent. Always exactly six cells: an unset preset shows "--" rather +--- than giving its cell up, so the position is the cell's place in the row. +--- Everything live rides in closures -- colours and label text -- so the +--- cells themselves never move or resize, and presets edited in another +--- instance's editor show up without a rebuild. +--- spec.cellH, spec.font and spec.rounded come from the screen file's scale; +--- spec.w is the width the row has to fit in. +--- Returns nil when the presets feature is off or no module is present. +function VTXDisplay.buildCells(spec) + if not (VTXAdmin.hasModule() and PresetsStorage.enabled) then + return nil + end + local gap = 4 + local font = spec.font or SMLSIZE + -- One width for all six, measured against the widest label a cell can carry. + local cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + -- A zone too narrow for six content-sized cells squeezes them rather than + -- clipping the row: all six showing outranks their padding, and outranks + -- the tier's cell font too -- the type steps down before the text would. + if spec.w then + local fit = math.floor((spec.w - 5 * gap) / 6) + if font ~= SMLSIZE and fit < cellW and fit < (lcd.sizeText("R8", font)) + 2 * lvgl.PAD_TINY then + font = SMLSIZE + cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + end + cellW = math.min(cellW, math.max(fit, (lcd.sizeText("--", font)))) + end + local function isActive(idx) + return PresetsStorage.latch.lastPos == idx + end + local function cellText(idx) + local p = PresetsStorage.items[idx] + if p.band == 0 then + return "--" + end + return table.concat({ VTXAdmin.BAND_LETTERS[p.band] or "?", p.channel }) + end + local cells = {} + for idx = 1, 6 do + cells[idx] = { + type = lvgl.RECTANGLE, + w = cellW, + h = spec.cellH, + filled = true, + rounded = spec.rounded, + color = function() + return isActive(idx) and COLOR_THEME_FOCUS or COLOR_THEME_DISABLED + end, + -- Part opacity is background-only on a rectangle, so the inactive cells + -- sit back while their labels keep full weight. + opacity = function() + return isActive(idx) and 255 or 90 + end, + children = { + { + type = lvgl.LABEL, + w = cellW, + align = CENTER + VCENTER, + font = font, + color = function() + return isActive(idx) and COLOR_THEME_PRIMARY2 or COLOR_THEME_SECONDARY1 + end, + text = function() + return cellText(idx) + end, + }, + }, + } + end + return { + type = lvgl.BOX, + flexFlow = lvgl.FLOW_ROW, + borderPad = 0, + -- Tighter than the theme paddings: six cells read as one control when the + -- gaps between them are beats, not breaks. + flexPad = gap, + align = LEFT, + visible = VTXAdmin.hasModule, + children = cells, + } +end + +--- The headline row: the widget's name, then the band and channel, on one line. +--- For the tiers with no row to spare for a title of its own. The name keeps +--- the small muted caption type every other tier titles itself with; only the +--- reading takes the tier's font and the accent, so the emphasis falls on the +--- reading at every widget size. +--- extras are trailing labels, for a tier with nowhere else to put them. +function VTXDisplay.buildHeadline(w, font, extras) + local children = { + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + }, + { + type = lvgl.LABEL, + align = LEFT, + font = font, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + }, + { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + }, + } + for i = 1, extras and #extras or 0 do + children[#children + 1] = extras[i] + end + return { + type = lvgl.BOX, + w = w, + align = LEFT + VCENTER, + flexFlow = lvgl.FLOW_ROW, + -- Wider than the tier's other gaps. At one size and with only colour telling + -- the caption from the reading, PAD_TINY leaves "VTX Admin" and "R1" reading + -- as one run-together word. + flexPad = lvgl.PAD_MEDIUM, + borderPad = 0, + children = children, + } +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..4facb68 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua @@ -0,0 +1,343 @@ +--------------------------------------------------------------------------- +-- 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. -- +-- -- +-- The page is built once, on full-screen entry, and never rebuilt, so -- +-- focus and scroll survive every value change. Values that change under -- +-- it -- a collection switch swapping the preset rows, a 6POS move -- +-- rewriting VTXAdmin.desired -- reach the screen through each control's -- +-- get() callback. choice and toggle re-poll it on every supported -- +-- firmware; numberEdit re-polls only on EdgeTX 2.12.0+, so on older -- +-- firmware its digits catch up when the page is next entered. -- +--------------------------------------------------------------------------- + +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 + +-- One label per collection, from the storage's count -- the only place the +-- number of collections lives. +local COLLECTION_VALUES = {} +for i = 1, PresetsStorage.COLLECTION_COUNT do + COLLECTION_VALUES[i] = table.concat({ "Collection ", i }) +end + +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 + 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") + + createChoiceRow(fields, "Collection", COLLECTION_VALUES, function() + return PresetsStorage.collection + end, function(idx) + PresetsStorage.selectCollection(idx) + end) + + createHintRow(fields, "Assign a Band and Channel to each 6POS switch position. Switching collection swaps all six.") + + 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..85d31bd --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -0,0 +1,214 @@ +--------------------------------------------------------------------------- +-- 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. +WidgetUI.breakpoints = { + topBarW = 200, + sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. + sixthStackH = 64, + sixthStackW = 300, + 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) + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. + wideW = 560, +} + +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +WidgetUI.fonts = { + sixth = { status = BOLD, cells = SMLSIZE, cellH = 22 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 26 }, + third = { hero = MIDSIZE, cells = STDSIZE, cellH = 28 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 30 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 32 }, +} + +-- The corner radius on the preset cells, in this screen's pixels. +local CELL_ROUNDED = 6 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) + return VTXDisplay.buildCells({ + cellH = cellH or f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, + }) +end + +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + if h >= bp.sixthStackH and w >= bp.sixthStackW then + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) + return + end + end + + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + } + if w >= bp.wideW then + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. +function WidgetUI.buildQuarter(w, h, opa) + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3: the whole card at the tier's own type scale. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2: the whole card. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- 1/1: the whole card, one step up in type. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +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..cf00b3a --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -0,0 +1,186 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - UI for 320x480 (Portrait) -- +-- FlySky EL18 — vertical screen -- +--------------------------------------------------------------------------- + +local ctx = ... +local bgOpacity = ctx.bgOpacity +local VTXDisplay = ctx.VTXDisplay +local WidgetLayout = ctx.WidgetLayout + +local WidgetUI = {} + +-- Breakpoints: absolute pixel values for 320x480 portrait. +-- Portrait zones are tall for their width, so every rung starts higher than +-- on the landscape screens. No stacked 1/6 variant: below sixthH the zone is +-- a custom sliver with one row in it. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 70, + quarterH = 100, + thirdH = 140, + halfH = 210, + -- Past the top bar this only gates the 1/6 inline row's preset cells; the + -- screen itself is 320 wide, so a full-width zone clears it. + wideW = 240, +} + +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +WidgetUI.fonts = { + -- No cellH: the 1/6 inline row sizes its cells to the zone itself. + sixth = { status = BOLD, cells = SMLSIZE }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 18 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 18 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 24 }, +} + +-- The corner radius on the preset cells, in this screen's pixels. +local CELL_ROUNDED = 3 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) + return VTXDisplay.buildCells({ + cellH = cellH or f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, + }) +end + +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + return rows +end + +--- 1/6: a single inline row -- the name where a full-width zone has the +--- width for it, the status or the band and channel with its power level, +--- then the preset cells. No stacked variant here. +--- Fixed-width band column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + } + if w >= bp.wideW then + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. +function WidgetUI.buildQuarter(w, h, opa) + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3: the whole card at the tier's own type scale. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2: the whole card. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- 1/1: the whole card, one step up in type. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +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..8e4689d --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -0,0 +1,214 @@ +--------------------------------------------------------------------------- +-- 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, + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. + sixthStackH = 40, + sixthStackW = 180, + quarterH = 70, + thirdH = 100, + halfH = 125, + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. + wideW = 340, +} + +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +WidgetUI.fonts = { + sixth = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 20 }, + third = { hero = MIDSIZE, cells = SMLSIZE, cellH = 22 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 26 }, +} + +-- The corner radius on the preset cells, in this screen's pixels. +local CELL_ROUNDED = 4 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) + return VTXDisplay.buildCells({ + cellH = cellH or f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, + }) +end + +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + if h >= bp.sixthStackH and w >= bp.sixthStackW then + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) + return + end + end + + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + } + if w >= bp.wideW then + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. +function WidgetUI.buildQuarter(w, h, opa) + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3: the whole card at the tier's own type scale. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2: the whole card. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- 1/1: the whole card, one step up in type. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +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..cc2cf04 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -0,0 +1,216 @@ +--------------------------------------------------------------------------- +-- 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. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + -- The 1/6 tier stacks the preset cells on a second row from here up, + -- and only where the six cells have the width for it. + sixthStackH = 40, + sixthStackW = 180, + quarterH = 62, + thirdH = 118, + halfH = 147, + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. + wideW = 340, +} + +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +-- The third tier's hero stays BOLD: its rung starts at 62px here, which is +-- too short for MIDSIZE over a cell row. +WidgetUI.fonts = { + sixth = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 20 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 20 }, + half = { hero = MIDSIZE, cells = STDSIZE, cellH = 24 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 26 }, +} + +-- The corner radius on the preset cells, in this screen's pixels. +local CELL_ROUNDED = 4 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) + return VTXDisplay.buildCells({ + cellH = cellH or f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, + }) +end + +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + return rows +end + +--- 1/6: the headline over the preset cells, wherever the zone holds two rows. +--- Two rows are what make room for the name: with the cells on the row below, +--- the one above is left to the name and the reading, and neither has to give. +--- It fits at all only on the container's padding: at this tier PAD_SMALL top +--- and bottom is the whole difference between two rows and one. So the +--- vertical padding gives and the left edge keeps it -- that edge is what a +--- widget in the zone above lines its own text up against. +--- A zone too short for two rows, or too narrow to carry six cells on one, +--- falls back to a single inline row; the name leads it where a full-screen +--- zone has the width for both. Fixed-width band column prevents layout +--- jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + if h >= bp.sixthStackH and w >= bp.sixthStackW then + local cellRow = cells(f, w) + if cellRow then + WidgetLayout.column(w, h, opa, { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + cellRow, + }, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_TINY, + -- The cells sit against this edge, and flush against it their fills + -- read as clipped; the top keeps the trim because text carries its + -- own leading. + bottom = lvgl.PAD_SMALL, + }) + return + end + end + + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + } + if w >= bp.wideW then + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font -- rather than the stacked + -- tier's cell height. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. +function WidgetUI.buildQuarter(w, h, opa) + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows, { + left = lvgl.PAD_SMALL, + right = lvgl.PAD_SMALL, + top = lvgl.PAD_SMALL, + -- A breath more than the default: the cells are the bottom row, and their + -- fills flush against the edge read as clipped. + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3: the whole card at the tier's own type scale. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2: the whole card. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- 1/1: the whole card, one step up in type. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +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..aaa397d --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -0,0 +1,179 @@ +--------------------------------------------------------------------------- +-- 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. +-- No stacked 1/6 variant: this screen's 1/6 zones are too short for two rows +-- even on trimmed padding. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 38, + quarterH = 54, + thirdH = 76, + halfH = 100, + -- From here up a zone spans the screen rather than half of it. Past the top + -- bar this only gates the 1/6 inline row's preset cells, which need the + -- width of a full-screen zone to share one line with the readings. + wideW = 240, +} + +-- Every tier that draws the preset cells sizes them here: cellH is the cell +-- box in this screen's pixels, cells the font inside it. +WidgetUI.fonts = { + -- No cellH: the 1/6 inline row sizes its cells to the zone itself. + sixth = { status = BOLD, cells = SMLSIZE }, + quarter = { status = BOLD, cells = SMLSIZE, cellH = 16 }, + third = { hero = BOLD, cells = SMLSIZE, cellH = 16 }, + half = { hero = MIDSIZE, cells = SMLSIZE, cellH = 20 }, + full = { hero = DBLSIZE, cells = STDSIZE, cellH = 22 }, +} + +-- The corner radius on the preset cells, in this screen's pixels. +local CELL_ROUNDED = 3 + +-- ============================================================================ +-- Minimized layout builders (by widget height tier) +-- ============================================================================ + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +--- The status line as a flex child, for the tiers whose headline has to carry +--- it inline: "VTX Admin Loading..." is the whole first line while the VTX +--- is quiet. +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- The preset cells sized for one tier and fitted to the zone's width; cellH +--- overrides the tier's own for a layout that sizes them to its zone. nil +--- when the presets feature is off, same as the builder. +local function cells(f, w, cellH) + return VTXDisplay.buildCells({ + cellH = cellH or f.cellH, + font = f.cells, + rounded = CELL_ROUNDED, + w = w - 2 * lvgl.PAD_SMALL, + }) +end + +--- The card the 1/3, 1/2 and 1/1 tiers share: header with the pit state, the +--- hero with its power level riding beside it, the preset cells. +local function cardRows(f, w) + local heroStatus, hero = VTXDisplay.buildHero(f.hero) + local rows = { VTXDisplay.buildHeader(w), heroStatus, hero } + rows[#rows + 1] = cells(f, w) + return rows +end + +--- 1/6: a single inline row -- the name where a full-width zone has the +--- width for it, the status or the band and channel with its power level, +--- then the preset cells. No stacked variant here. +--- Fixed-width band column prevents layout jumping when values change. +function WidgetUI.buildSixth(w, h, opa) + local bp = WidgetUI.breakpoints + local f = WidgetUI.fonts.sixth + local columns = {} + if w >= bp.wideW then + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = "VTX Admin", + } + end + columns[#columns + 1] = statusLabel() + columns[#columns + 1] = { + type = lvgl.LABEL, + -- Reserved against the widest reading rather than a screen fraction, so + -- the row stays put when the channel changes and gives the rest to the + -- cells. + w = (lcd.sizeText("R8", f.status)) + lvgl.PAD_SMALL, + font = f.status, + color = VTXDisplay.heroColor, + text = VTXDisplay.bandChannel, + visible = VTXDisplay.showChannel, + } + columns[#columns + 1] = { + type = lvgl.LABEL, + align = LEFT, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = VTXDisplay.powerShort, + visible = VTXDisplay.showChannel, + } + if w >= bp.wideW then + -- The inline row is the cells' whole zone, so they take its height -- up + -- to a comfortable bubble around the font. + local fontH = select(2, lcd.sizeText("0", f.cells)) + columns[#columns + 1] = cells(f, w, math.min(h - 2 * lvgl.PAD_SMALL, fontH + 2 * lvgl.PAD_SMALL)) + end + + WidgetLayout.row(w, h, opa, columns) +end + +--- 1/4: two rows. Row 1: the headline, carrying the status while the VTX is +--- quiet -- this tier has no spare row to hold it. Row 2: the preset cells. +function WidgetUI.buildQuarter(w, h, opa) + local f = WidgetUI.fonts.quarter + local rows = { + VTXDisplay.buildHeadline(w, f.status, { statusLabel() }), + } + rows[#rows + 1] = cells(f, w) + WidgetLayout.column(w, h, opa, rows) +end + +--- 1/3: the whole card at the tier's own type scale. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2: the whole card. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- 1/1: the whole card, one step up in type. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +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