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..f6b2728 --- /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", "legacy"], + "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..1536300 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,140 @@ +# 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`, `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/ + dialogs.lua -- BW dialogs (both tools) + text_edit.lua -- BW text editor (bind tool) + lvgl/ + dialogs.lua -- color dialogs (both tools, widgets) + 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+)". + +## Legacy Script + +[`legacy/`](legacy/) holds `elrs.lua`, a single-file configuration tool meant for older radios that no longer support +the minimum EdgeTX version listed in [Compatibility](#compatibility). diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..1cf26a9 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,169 @@ +# 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 (one step in flight, retried when the radio's single output slot refuses it), 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/dialogs.lua` | BW full-screen dialogs (MIDSIZE title, body lines, optional bottom action labels), redrawn every frame: `draw` plus the version gate and the missing-module notice. Loaded by both tools' BW UIs | +| `SCRIPTS/ELRS/ui/lvgl/dialogs.lua` | Color-LCD dialogs: `showConfirm`/`showMessage` over the firmware popups, plus the version gate and the missing-module notice a tool raises before it has a page. Those two are terminal, so each takes the caller's `onExit` for the close box and the Exit button. Loaded by both tools' LVGL UIs; the widgets' full-screen pages reuse `noModuleChecklist` | +| `SCRIPTS/ELRS/loader.lua` | The tools' GC-guarded script loader: a full collection before each `loadScript` keeps fresh-install compile peaks from stacking. The one part consumers bootstrap with a bare `loadScript` | +| `SCRIPTS/ELRS/edgetx_version.lua` | The one home of the minimum EdgeTX requirement (2.11.6 / 2.12.1 / 3.0). Each tool's `main.lua` checks it once and hands `deps.versionOk` and the `REQUIRED_VERSIONS` dialog lines (`deps.requiredVersions`) to its UI chunk, whose `preCheck` owns the presentation. Keep `min_edgetx_version` in `edgetx.yml` in step | +| `SCRIPTS/ELRS/sensors.lua` | Generic EdgeTX telemetry reader (`getSensorValue` with a cached name-to-ID lookup), not CRSF-specific. Loaded by `crsf.lua`, which exposes it to every consumer as `crsf.getSensorValue`. A cached ID addresses a slot in the model that was loaded when it was resolved, so a consumer that survives a model change must call `crsf.resetSensorCache()` on that edge | +| `SCRIPTS/ELRS/file_storage.lua` | Generic key=value file persistence (`read`/`write`), schema-free. Loaded by the VTX Admin widget and the bind tool | +| `SCRIPTS/ELRS/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. | +| `flrc` | TX + RX connected at F1000. FLRC carries no SNR, so `RSNR` is a permanent 0 and the full-screen SNR row reads "n/a". | +| `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. +Command-step answers chunk the same way and, as in `CRSFEndpoint::parameterUpdateReq`, only a +`CMD_QUERY` fetches the continuation chunks; the state machine is not re-run for them. +`maxPacketBytes = 35` reproduces the 27-byte chunk of a 400K link at 500 Hz, where a confirm prompt +or a running-status answer spans two chunks. 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/legacy/README.md b/legacy/README.md new file mode 100644 index 0000000..12a229d --- /dev/null +++ b/legacy/README.md @@ -0,0 +1,13 @@ + +`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. + +## Old ELRS.lua + +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. + +### 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. + +### 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) diff --git a/elrs.lua b/legacy/elrs.lua similarity index 100% rename from elrs.lua rename to legacy/elrs.lua diff --git a/mockup/README.md b/legacy/mockup/README.md similarity index 100% rename from mockup/README.md rename to legacy/mockup/README.md diff --git a/mockup/elrsmock.lua b/legacy/mockup/elrsmock.lua similarity index 100% rename from mockup/elrsmock.lua rename to legacy/mockup/elrsmock.lua diff --git a/screenshots/tool_bind.png b/screenshots/tool_bind.png new file mode 100644 index 0000000..6921257 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..dedfb13 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..6db88d0 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..b31e657 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..93f1960 --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/csrfsimulator.lua @@ -0,0 +1,1695 @@ +-- ============================================================================ +-- CRSF Simulator: packet-level mock for crossfireTelemetryPop/Push +-- Loaded by crsf.lua setMock() on -simu builds; returns +-- { pop, push, getSensorValue, moduleFound } +-- ============================================================================ + +local shim = loadScript("/SCRIPTS/CRSFSimulator/shim.lua")() + +-- Scenarios (set config.scenario below): +-- normal TX + RX connected, full telemetry +-- no_telemetry TX only, no RX +-- reconnect RX appears after ~5 s +-- model_mismatch connected, model mismatch flag +-- mismatch_cycle mismatched link, ~10 s up / ~5 s down, forever; +-- expect a status request per connect edge, then ~1 Hz +-- mismatch_recovery mismatch clears after ~10 s, link stays up; the module +-- only answers when asked, so the widget must keep polling +-- weak_link RQly ~60, RSSI ~-85 dBm; gated polls stay quiet +-- armed armed warning flag +-- single_antenna 2RSS pinned to 0 (no diversity) +-- flrc F1000, RSNR always 0 +-- unrated_rate no published sensitivity; floor-based UI must fall back +-- slow_loading PARAMETER_READ answers delayed ~2 s each +-- no_module no CRSF module +-- critical_error baud rate error; suppress write (0x2E) clears it +-- Connected scenarios also arm while CH5 (AUX1) is high. +local config = { + scenario = "normal", + -- Lower to emulate a slow link (firmware floor 15) and force chunking + maxPacketBytes = 64, +} + +local CRSF = { + 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_HEADER_V1 = 0x30, -- v1 | start-of-frame | seq 0 + MSP_ELRS_RXTX_CONFIG = 0x2D, + MSP_RXTX_UID = 0x00, + MSP_RXTX_BIND_PHRASE = 0x01, + + ADDRESS_BROADCAST = 0x00, + ADDRESS_HANDSET = 0xEA, + ADDRESS_RX = 0xEC, + ADDRESS_TX = 0xEE, + + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + FLOAT = 8, + TEXT_SELECTION = 9, + STRING = 10, + FOLDER = 11, + INFO = 12, + COMMAND = 13, + + ELRS_SERIAL_ID = 0x454C5253, + + CMD_IDLE = 0, + CMD_CLICK = 1, + CMD_EXECUTING = 2, + CMD_ASKCONFIRM = 3, + CMD_CONFIRMED = 4, + CMD_CANCEL = 5, + CMD_QUERY = 6, + + -- TXModuleEndpoint.cpp supressCriticalErrors() + FIELD_ID_SUPPRESS_CRITICAL_ERRORS = 0x2E, +} + +-- SX128X rates from common.cpp; defaultTlm indexes the Telem Ratio options +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 +} + +-- rx_config_pwm_t per Output Ch +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 }, +} + +-- Firmware gives each Lua state its own frame queue: a shared log with +-- per-consumer cursors, new consumers start at the tail. + +local frameLog = {} +local logTotal = 0 +local logPruned = 0 +local cursors = setmetatable({}, { __mode = "k" }) +local defaultConsumer = {} + +-- OTA relay delay: delivered after the next nil pop +local deferredQueue = {} +local deferredReady = false + +local SLOW_LOADING_DELAY_TICKS = 200 -- 2 s per field +local delayedResponseQueue = {} + +-- Firmware updates folder names on the next event loop, not in the write +local FOLDER_NAMES_UPDATE_TICKS = 2 -- 20 ms +local folderNamesReadyAt = 0 +local folderNamesDevice = nil + +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 + +-- Copy: consumers decode in place +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 + + if cursor < logTotal then + deferredReady = false + return deliver(consumer, cursor + 1) + end + + 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 + + if #deferredQueue > 0 then + deferredReady = true + end + + return nil +end + +local function appendString(tbl, str) + for i = 1, #str do + tbl[#tbl + 1] = string.byte(str, i) + end + tbl[#tbl + 1] = 0 +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 +-- ============================================================================ + +local function encodeDeviceInfo(device, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_HANDSET + data[2] = device.id + appendString(data, device.name) + appendU32BE(data, device.serialNo or CRSF.ELRS_SERIAL_ID) + appendU32BE(data, device.hwVer or 0) + appendU32BE(data, device.swVer or 0x00030500) -- 3.5.0 + data[#data + 1] = device.fieldCount + data[#data + 1] = 0 -- parameter version + return data +end + +local function encodeParameterEntry(device, param, chunk, destAddr) + local data = {} + data[1] = destAddr or CRSF.ADDRESS_HANDSET + data[2] = device.id + data[3] = param.id + data[4] = 0 -- chunks remaining + data[5] = param.parent or 0 + data[6] = param.type + if param.hidden then + data[6] = bit32.bor(data[6], 0x80) + end + + appendString(data, param.dynName or param.name) + + local t = bit32.band(param.type, 0x7F) + + if t == CRSF.TEXT_SELECTION then + appendString(data, param.options) + data[#data + 1] = param.value or 0 + data[#data + 1] = 0 + 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 + data[#data + 1] = 0 + appendString(data, param.units or "") + elseif t == CRSF.COMMAND then + data[#data + 1] = param.status or CRSF.CMD_IDLE + data[#data + 1] = param.timeout or 200 -- 10 ms ticks + appendString(data, param.info or "") + elseif t == CRSF.FOLDER then + 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 + 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 + data[#data + 1] = param.value or 0 + data[#data + 1] = param.min or 0 + data[#data + 1] = param.max or 255 + data[#data + 1] = param.default or 0 + appendString(data, param.units or "") + elseif t == CRSF.INT8 then + 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 + appendU16BE(data, param.value or 0) + appendU16BE(data, param.min or 0) + appendU16BE(data, param.max or 65535) + appendU16BE(data, param.default or 0) + appendString(data, param.units or "") + elseif t == CRSF.FLOAT then + 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 + + -- As CRSFEndpoint::sendParameter: 6 bytes header/CRC + fieldId + chunksRemain + 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 + +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 + appendU16BE(data, goodPkts or 0) + data[#data + 1] = flags or 0 + appendString(data, flagsInfo or "") + return data +end + +-- ============================================================================ +-- TX device (TXModuleParameters.cpp) +-- ============================================================================ + +-- STR_LUA_ALLAUX_UPDOWN; \192/\193 are the ELRS 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, + 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 = "", + }, + + { 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 = "", + }, + + { 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 = "" }, + -- Not "-": that hides Pitmode and the folder name's power/pit segments + { + 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 = 200, + info = "", + }, + + { 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 = 200, + info = "", + persistent = true, + }, -- runs until cancelled + { + id = 18, + parent = 16, + type = CRSF.COMMAND, + name = "Enable Rx WiFi", + status = CRSF.CMD_IDLE, + timeout = 200, + info = "", + persistent = true, + }, -- runs until cancelled + + { + id = 19, + parent = 0, + type = CRSF.COMMAND, + name = "Bind", + status = CRSF.CMD_IDLE, + timeout = 200, + info = "", + progress = { "Binding...", "Waiting for RX...", "RX found", "Saving..." }, + }, + + -- Echoes Bind Phrase: tests that a STRING write reloads siblings + { id = 24, parent = 0, type = CRSF.INFO, name = "Phrase Echo", value = "default" }, + + { + id = 20, + parent = 0, + type = CRSF.STRING, + name = "Bind Phrase", + value = "default", + maxlen = 16, + }, + + { + id = 21, + parent = 0, + type = CRSF.FLOAT, + name = "Freq Offset", + value = 0, + min = -5000, + max = 5000, + default = 0, + prec = 2, + step = 1, + units = "kHz", + }, + + { id = 22, parent = 0, type = CRSF.INFO, name = "Bad/Good", value = "0/250", hidden = true }, + + { id = 23, parent = 0, type = CRSF.INFO, name = "3.5.0 ISM2G4", value = "825ed8" }, + + -- Not a real TX parameter; tests INT8 sign handling + { + id = 25, + parent = 0, + type = CRSF.INT8, + name = "RF Gain", + value = -3, + min = -10, + max = 10, + default = 0, + units = "dB", + }, + }, +} + +-- ============================================================================ +-- RX device (RXParameters.cpp) +-- ============================================================================ + +local rxDevice = { + id = CRSF.ADDRESS_RX, + name = "Bob 2400RX", + serialNo = CRSF.ELRS_SERIAL_ID, + hwVer = 0, + swVer = 0x00030500, -- 3.5.0 + fieldCount = 25, + 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", + }, + + { 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 = "", + }, + + { 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 = "", + }, + + { 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 = "", + }, + + { 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 = "", + }, + + -- COMMAND that changes a sibling value (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 = 200, + info = "", + progress = { "Detecting...", "Reading IMU..." }, + onComplete = function(device, findParam) + local o = findParam(device, 20) + if o then + o.value = ((o.value or 0) + 1) % 4 + end + end, + }, + + { + 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 = 200, + info = "", + }, + + { id = 24, parent = 0, type = CRSF.INFO, name = "Model Id", value = "12" }, + + { id = 25, parent = 0, type = CRSF.INFO, name = "RX Version", value = "3.5.0 825ed8" }, + }, +} + +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 + +-- findSelectionLabel(); index is 0-based +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 + +-- ============================================================================ +-- Firmware-derived updates (TXModuleParameters.cpp) +-- ============================================================================ + +-- updateFolderNames(); TX only, RX reuses these ids +local function updateFolderNames(device) + if device.id ~= CRSF.ADDRESS_TX then + return + end + + 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 + + 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 + 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 + +-- TLMratioEnumToValue() +local function tlmRatioEnumToValue(enumval) + if enumval <= 1 then + return 1 + end -- Std/Off + if enumval >= 9 then + return 1 + end -- Race + return math.floor(2 ^ (9 - enumval)) +end + +-- TLMBurstMaxForRateRatio() +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 + +-- updateTlmBandwidth() +local function updateTlmBandwidth(device) + local packetRate = findParam(device, 1) + local telemRatio = findParam(device, 2) + local switchMode = findParam(device, 3) + 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 or Race + if tlmVal == 0 or tlmVal == 9 then + local defaultDiv = tlmRatioEnumToValue(rateCfg.defaultTlm) + telemRatio.units = " (1:" .. defaultDiv .. ")" + return + end + + if tlmVal == 1 then + telemRatio.units = "" + return + end + + local hz = rateCfg.hz + local ratioDiv = tlmRatioEnumToValue(tlmVal) + local burst = tlmBurstMaxForRateRatio(hz, ratioDiv) + + -- Wide: 10 bytes per call, Hybrid: 5 + 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)) + + -- Telemetry packed into LinkStats; sizeof(OTA_LinkStats_s) = 4 + if isFullRes then + bandwidth = bandwidth + 8 * (10 - 4) + end + + telemRatio.units = " (" .. bandwidth .. "bps)" +end + +-- updateVtxAdminOpts(); hiding Send VTx is mock-only, to test COMMAND sibling re-reads +local function updateVtxAdminOpts(device) + if device.id ~= CRSF.ADDRESS_TX then + return + end + + local vtxBand = findParam(device, 11) + local vtxChan = findParam(device, 12) + local vtxPwr = findParam(device, 13) + local vtxPit = findParam(device, 14) + local vtxSend = findParam(device, 15) + if vtxBand and vtxChan and vtxPwr and vtxPit and vtxSend then + local disabled = (vtxBand.value or 0) == 0 + vtxChan.hidden = disabled or nil + vtxPwr.hidden = disabled or nil + vtxPit.hidden = (disabled or (vtxPwr.value or 0) == 0) or nil + vtxSend.hidden = disabled or nil + end +end + +updateFolderNames(txDevice) +updateTlmBandwidth(txDevice) +updateVtxAdminOpts(txDevice) + +-- ============================================================================ +-- Scenario State +-- ============================================================================ + +local reconnectDelay = 500 -- 5 s +local startTime = nil -- first push/pop +local mismatchClearDelay = 1000 -- 10 s + +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 + +-- Flag bits (TXModuleEndpoint.h): 0 connected, 1 status1, 2 model mismatch, +-- 3 armed, 4 warning1, 5 error connected, 6 error baud rate +local criticalErrorsSuppressed = false + +local function isArmed() + return (getOutputValue(4) or 0) > 0 -- CH5, 0-based +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 + 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 + else + flags = 0x41 -- connected + baud rate error + 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" + or config.scenario == "flrc" + then + flags = 0x01 -- connected + else + flags = 0x00 -- no telemetry + end + if bit32.btest(flags, 0x01) and isArmed() then + flags = bit32.bor(flags, 0x08) + end + return flags +end + +-- Highest bit wins, as sendELRSstatus() +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 + +-- ============================================================================ +-- Commands +-- ============================================================================ + +local commandStates = {} -- "deviceId:paramId" +-- CRSFEndpoint::nextStatusChunk, per device +local nextStatusChunk = {} + +local function getCommandKey(deviceId, paramId) + return tostring(deviceId) .. ":" .. tostring(paramId) +end + +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 + -- WiFi/BLE commands confirm only while connected ("normal") + local needsConfirm = param.persistent and config.scenario == "normal" + if newStatus == CRSF.CMD_CLICK and needsConfirm then + state.status = CRSF.CMD_ASKCONFIRM + state.info = "Confirm " .. param.name .. "?" + else + state.status = CRSF.CMD_EXECUTING + if param.persistent then + state.info = "Executing..." + state.queriesRemaining = nil -- until cancelled + elseif param.progress then + 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 + 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 = "" + if param.onComplete then + param.onComplete(device, findParam) + end + elseif param.progress then + state.progressIndex = (state.progressIndex or 1) + 1 + state.info = param.progress[state.progressIndex] or state.info + end + end + end + + param.status = state.status + param.info = state.info +end + +-- ============================================================================ +-- MSP bind UID +-- ============================================================================ + +-- TX and RX start mismatched +local mspUid = { + [CRSF.ADDRESS_TX] = { 13, 213, 105, 32, 0, 1 }, + [CRSF.ADDRESS_RX] = { 13, 213, 105, 32, 0, 2 }, +} + +-- Deterministic, not the firmware's MD5 +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 + +local function encodeMspUidResponse(deviceId, destAddr, uid) + return { + destAddr, + deviceId, + CRSF.MSP_HEADER_V1, + 7, -- subcmd + 6 UID bytes + CRSF.MSP_ELRS_RXTX_CONFIG, + CRSF.MSP_RXTX_UID, + uid[1], + uid[2], + uid[3], + uid[4], + uid[5], + uid[6], + } +end + +-- ============================================================================ +-- push / pop +-- ============================================================================ + +local function mockPush(command, data) + if not startTime then + startTime = getTime() + end + + -- One log line per frame so tests can count wire traffic + print(shim.tableConcat({ + "CRSFSIM push t=", + getTime(), + " cmd=", + command, + " dst=", + data and data[1] or "-", + " field=", + data and data[3] or "-", + " arg=", + data and data[4] or "-", + })) + + if command == CRSF.FRAMETYPE_DEVICE_PING then + local dest = data[1] or CRSF.ADDRESS_BROADCAST + local replyTo = data[2] or CRSF.ADDRESS_HANDSET + + -- Only a broadcast ping is forwarded over the air + if dest == CRSF.ADDRESS_BROADCAST or dest == CRSF.ADDRESS_TX then + queuePush(CRSF.FRAMETYPE_DEVICE_INFO, encodeDeviceInfo(txDevice, replyTo)) + end + + 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 + 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 + param = { id = 0, parent = 0, type = CRSF.FOLDER, name = device.name, _device = device } + else + param = findParam(device, fieldId) + end + if param then + 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 + param._device = param._device or device + local entry = encodeParameterEntry(device, param, chunk, destAddr) + param._device = nil + if config.scenario == "slow_loading" then + 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 + local deviceId = data[1] + local fieldId = data[3] + local writeValue = data[4] + + -- Field 0: ELRS status request + 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 + + 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 + -- As parameterUpdateReq: a query mid-response fetches the next chunk + local destAddr = data[2] or CRSF.ADDRESS_HANDSET + local chunk = nextStatusChunk[device.id] or 0 + if writeValue ~= CRSF.CMD_QUERY or chunk == 0 then + handleCommandWrite(device, param, writeValue) + chunk = 0 + end + local entry = encodeParameterEntry(device, param, chunk, destAddr) + if entry[4] == 0 then -- chunks remaining + nextStatusChunk[device.id] = 0 + else + nextStatusChunk[device.id] = chunk + 1 + end + queuePush(CRSF.FRAMETYPE_PARAMETER_SETTINGS_ENTRY, entry) + else + 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) + 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 + 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 Ch loads its siblings; sibling edits save back + if device.id == CRSF.ADDRESS_RX then + if param.id == 9 then + 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 + 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 + + -- Firmware updates names in its event loop and pushes no folder + -- entry after a write; Lua must re-read + folderNamesReadyAt = getTime() + FOLDER_NAMES_UPDATE_TICKS + folderNamesDevice = device + end + end + end + return true + elseif command == CRSF.FRAMETYPE_MSP_REQ then + 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])) + -- No RX, no answer: exercises the Bind tool's bounded retry + elseif deviceId == CRSF.ADDRESS_RX and isRxAvailable() then + queuePushDeferred(CRSF.FRAMETYPE_MSP_RESP, encodeMspUidResponse(deviceId, replyTo, mspUid[deviceId])) + end + end + return true + elseif command == CRSF.FRAMETYPE_MSP_WRITE then + -- No ack from firmware + 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 + 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: logged only + return true + end + + return true +end + +local function mockPop(consumer) + if not startTime then + startTime = getTime() + end + + 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) + + if folderNamesDevice and getTime() >= folderNamesReadyAt then + updateFolderNames(folderNamesDevice) + updateTlmBandwidth(folderNamesDevice) + updateVtxAdminOpts(folderNamesDevice) + folderNamesDevice = nil + end + + return command, data +end + +local moduleFound = (config.scenario ~= "no_module") + +-- ============================================================================ +-- Telemetry sensors +-- ============================================================================ + +-- Downlink (TQly/TRSS) runs a few dB behind uplink: RX transmits at lower power +local scenarioTelemetry = { + normal = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + RSNR = 8, + 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 }, + }, + single_antenna = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -84, + ["2RSS"] = 0, + RQly = 97, + RSNR = 8, + 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, + RSNR = 8, + 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 }, + }, + -- F1000 (v3 index 13) + flrc = { + TPWR = 250, + RFMD = 13, + ["1RSS"] = -70, + ["2RSS"] = -74, + RQly = 100, + RSNR = 0, + ANT = 0, + TQly = 100, + TRSS = -80, + RxBt = 16.4, + Curr = 18.0, + FM = "ACRO", + Sats = 10, + GSpd = 45.0, + Alt = 30, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, + -- 9K1000 (v3 index 18), no sensitivity figure + unrated_rate = { + TPWR = 250, + RFMD = 18, + ["1RSS"] = -79, + ["2RSS"] = -84, + RQly = 98, + RSNR = 8, + 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 signal: must clear the model-match poll's -70 dBm gate + model_mismatch = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + RSNR = 12, + ANT = 1, + TQly = 100, + TRSS = -61, + RxBt = 15.8, + Curr = 0.5, + }, + mismatch_cycle = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + RSNR = 12, + ANT = 1, + TQly = 100, + TRSS = -61, + RxBt = 15.8, + Curr = 0.5, + FM = "ACRO", + Sats = 12, + GSpd = 25.3, + Alt = 142, + GPS = { lat = 54.6872, lon = 25.2797 }, + }, + -- No sensorToggle entry: the link must never drop + mismatch_recovery = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -55, + ["2RSS"] = -58, + RQly = 95, + RSNR = 12, + ANT = 1, + TQly = 100, + TRSS = -61, + RxBt = 15.8, + Curr = 0.5, + }, + -- Below every poll gate (RQly <= 90, RSSI <= -70 dBm) + weak_link = { + TPWR = 250, + RFMD = 7, + ["1RSS"] = -85, + ["2RSS"] = -88, + RQly = 60, + RSNR = 2, + 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 }, + }, + no_telemetry = { + -- Sensors left from a previous flight; served as 0 + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + RSNR = 8, + 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 }, + }, + reconnect = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + RSNR = 8, + ANT = 1, + TQly = 100, + TRSS = -95, + RxBt = 15.2, + Curr = 12.5, + }, + slow_loading = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + RSNR = 8, + 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 = { + TPWR = 50, + RFMD = 7, + ["1RSS"] = -87, + ["2RSS"] = -93, + RQly = 99, + RSNR = 8, + 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 }, + }, +} + +-- +/- range; unlisted sensors are static +local sensorJitter = { + ["1RSS"] = 3, + ["2RSS"] = 3, + TRSS = 3, + RQly = 2, + TQly = 2, + RSNR = 2, + RxBt = 0.05, + Curr = 2.0, + GSpd = 3.0, + Alt = 5, +} + +local sensorCeiling = { + RQly = 100, + TQly = 100, +} + +-- Fixed sequences, one step per second; overrides base and jitter +local sensorToggle = { + normal = { + ANT = { 1, 1, 1, 1, 1, 0, 0, 0, 0, 0 }, -- ~5 s per antenna + }, + single_antenna = { + ["2RSS"] = { 0 }, + }, + flrc = { + RSNR = { 0 }, + }, + mismatch_cycle = { + RQly = { 95, 95, 95, 95, 95, 95, 95, 95, 95, 95, 0, 0, 0, 0, 0 }, + }, +} +local toggleStep = 0 + +local telemetryCache = {} +local lastTelemetryUpdate = 0 +local TELEMETRY_UPDATE_TICKS = 100 -- 1 s + +local function updateTelemetryCache() + local now = getTime() + if now - lastTelemetryUpdate < TELEMETRY_UPDATE_TICKS then + return + end + lastTelemetryUpdate = now + + local t = scenarioTelemetry[config.scenario] + telemetryCache = {} + if not t then + return + end + + if isRxAvailable() then + 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 + local ceiling = sensorCeiling[sensorId] + if ceiling and val > ceiling then + val = ceiling + end + telemetryCache[sensorId] = val + else + telemetryCache[sensorId] = base + end + end + end + + -- EdgeTX serves 0 for every sensor while RQly is 0 (luaGetValueAndPush) + if (telemetryCache.RQly or 0) == 0 then + for sensorId in pairs(t) do + telemetryCache[sensorId] = 0 + end + end +end + +-- 0 while the link is down (as EdgeTX); nil for sensors the scenario lacks +local function mockGetSensorValue(sensorId) + updateTelemetryCache() + return telemetryCache[sensorId] +end + +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..1c32161 --- /dev/null +++ b/src/SCRIPTS/CRSFSimulator/shim.lua @@ -0,0 +1,54 @@ +--------------------------------------------------------------------------- +-- CRSF simulator polyfills: B&W builds lack table.concat/remove/unpack -- +--------------------------------------------------------------------------- + +local shim = {} + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +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 + +-- string.char(table.unpack(t)); iterative, a recursive unpack would blow the 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..a1401f8 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf.lua @@ -0,0 +1,267 @@ +--------------------------------------------------------------------------- +-- CRSF Protocol Singleton -- +-- -- +-- Protocol constants, pop/push (with the simulator mock seam) and -- +-- link-layer decoders. Each script instance has its own frame queue on -- +-- colour radios; consumers drain theirs with CRSF.drain(). -- +--------------------------------------------------------------------------- + +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, + + -- 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, +} + +-- Refreshed each time a drain empties the queue +CRSF.hasTelemetry = false + +-- ============================================================================ +-- Transport (setMock() swaps these in the simulator) +-- ============================================================================ + +--- Pop one frame. consumer is only used by the simulator mock. +-- Link state refreshes only on an empty pop, so frames land before the flip. +-- The TX zeroes RQly on disconnect. +function CRSF.pop(consumer) + local command, data = CRSF._popImpl(consumer) + if command == nil then + CRSF.hasTelemetry = (CRSF.getSensorValue("RQly") or 0) > 0 + end + return command, data +end + +function CRSF._popImpl(_consumer) + return crossfireTelemetryPop() +end + +--- Calls onFrame(consumer, command, data) for every queued frame, +-- e.g. 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 + +CRSF.getSensorValue = sensors.getSensorValue + +-- Call on model change: sensor IDs are per model +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 +-- ============================================================================ + +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, converting bytes to chars in place. +-- Safe because each pop returns a fresh table; decode a frame only once. +-- @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: a PARAMETER_WRITE from the TX to the handset. +-- 3.x+ never writes to the handset. +-- @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 broadcast is also forwarded over the air to the +-- RX; address a device directly unless discovering remote ones. +-- @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, 0, 0 }) +end + +--- Send a COMMAND bind request: to the TX it enters bind mode, to the RX +-- it unbinds the 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 CRSF diff --git a/src/SCRIPTS/ELRS/crsf_params.lua b/src/SCRIPTS/ELRS/crsf_params.lua new file mode 100644 index 0000000..0134f32 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_params.lua @@ -0,0 +1,422 @@ +--------------------------------------------------------------------------- +-- CRSF Parameter Codec -- +-- -- +-- PARAMETER_SETTINGS_ENTRY (0x2B) decoding, chunk reassembly and -- +-- parameter frame encoders. Never mutates frame data: reassemble() may -- +-- hand the caller's frame back as the decode buffer. -- +-- -- +-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_params.lua")(crsf). -- +--------------------------------------------------------------------------- + +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. Arrow bytes only occur in options. +-- @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 + +--- Refill values in place from a ';'-separated option list. Empty options +-- stay as "" (disabled slots). Maps legacy arrow bytes 0xC0/0xC1 to +-- CHAR_UP/CHAR_DOWN. +-- @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 + 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 + -- values keeps its identity; consumers watch this instead + 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 + +-- Keyed by wire type + 1; UINT32..INT64 unsupported, as in 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) + 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] + -- Other consumers' copies of the final chunk look like a fresh + -- single-frame entry; drop them until the next request cycle + 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 + if rx.data and chunksRemain ~= rx.expect then -- duplicate chunk + return nil + end + + local buffer + local offset + 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 + 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) + if #buffer <= offset + 2 then -- parent + type + one name byte + 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 +-- +-- Each returns (frameType, payload) for crsf.push(). deviceId is the +-- target, handsetId the reply-to address. Layouts per CRSFParameters.h. +-- ============================================================================ + +--- Encode a request for one chunk of a field's PARAMETER_SETTINGS_ENTRY. +-- Starts a new request cycle on rx; sends rx.chunk (0 = fresh entry). +-- @param rx the reassembly-state table +-- @param fieldId the field id to read +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), sent as two's complement. Default 1 byte. +-- @param field table with id, value and optional size +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 field table with id, value and optional maxlen +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 fieldId the command field's id +-- @param step the command step byte +function Params.encodeCommandStep(deviceId, handsetId, fieldId, step) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, fieldId, step } +end + +-- Pseudo-field: TXModuleEndpoint.cpp calls supressCriticalErrors() on 0x2E +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). +function Params.encodeSuppressCriticalErrors(deviceId, handsetId) + return crsf.CONST.FRAMETYPE_PARAMETER_WRITE, { deviceId, handsetId, FIELD_ID_SUPPRESS_CRITICAL_ERRORS, 0 } +end + +return Params diff --git a/src/SCRIPTS/ELRS/crsf_session.lua b/src/SCRIPTS/ELRS/crsf_session.lua new file mode 100644 index 0000000..7e1a788 --- /dev/null +++ b/src/SCRIPTS/ELRS/crsf_session.lua @@ -0,0 +1,637 @@ +--------------------------------------------------------------------------- +-- CRSF Parameter Session -- +-- Field store, load/write queues and command state for one device. -- +-- One instance per consumer (config tool, each VTX Admin widget). -- +-- Loaded via loadScript("/SCRIPTS/ELRS/crsf_session.lua")(crsf,params). -- +--------------------------------------------------------------------------- + +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 + +--- opts: deviceId, handsetId, responseTimeout (ticks; 50 local TX, 500 +--- remote), acceptUnsolicited (any field; default only the awaited one), +--- discovery (fills .devices), trackStatus (.status at 1 Hz), detectV1 +--- (latches .v1Detected), preload (queue subfolders after root load), +--- onFieldUpdate(field), onDeviceUpdate(device, isNew) +function CRSFSession.new(opts) + opts = opts or {} + return setmetatable({ + deviceId = opts.deviceId or crsf.CONST.ADDRESS_TX, + handsetId = opts.handsetId or crsf.CONST.ADDRESS_HANDSET, + deviceName = nil, + isElrsTx = nil, + fieldsCount = 0, + devices = {}, + command = nil, -- the command field driving the active popup + commandAt = 0, -- click tick; UIs time grace periods from it + status = { flags = 0, warning = "" }, -- identity stable; safe to cache + fieldHiddenChanged = nil, + v1Detected = nil, + rx = { chunk = 0, expect = -1 }, + + _acceptUnsolicited = opts.acceptUnsolicited, + _discovery = opts.discovery, + _trackStatus = opts.trackStatus, + _detectV1 = opts.detectV1, + _preload = opts.preload, + _respTimeout = opts.responseTimeout, + _onFieldUpdate = opts.onFieldUpdate, + _onDeviceUpdate = opts.onDeviceUpdate, + + _fields = {}, -- get-or-create: UI closures hold field tables + + _loadQueue = {}, -- LIFO + _nextReadAt = 0, + _nextQueryAt = 0, + _nextStatusAt = 0, + _nextPingAt = 0, + _lastWriteAt = 0, + _pendingFrame = nil, + + _writeQueue = {}, + _writeHead = 1, + _writeTail = 0, + + _refreshId = nil, + _refreshAt = 0, + _refreshLeft = 0, + _refreshAttempts = 0, + + _hadTelemetry = false, + _preloadArmed = nil, + _preloading = nil, + }, CRSFSession) +end + +-- Remote devices answer over the air link: 5 s vs 0.5 s locally +function CRSFSession:_responseTimeout() + if self._respTimeout then + return self._respTimeout + end + return self.isElrsTx and 50 or 500 +end + +-- ============================================================================ +-- Lifecycle +-- ============================================================================ + +--- Returns true when the device changed. +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 + local st = self.status + st.flags = 0 + st.connected = nil + st.modelMismatch = nil + st.criticalError = nil + + self:reloadAll() + return true +end + +function CRSFSession:drain() + crsf.drain(self, self._onFrame) +end + +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 + -- Another device's status interrupts our chunk stream + 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 + -- Request the next chunk now; commands continue only on CMD_QUERY + if self.command and self.command.id == fieldId then + self._nextQueryAt = 0 + elseif self._loadQueue[#self._loadQueue] == fieldId then + self._nextReadAt = 0 + elseif self._refreshId == fieldId then + self._refreshAt = now + self._refreshLeft = self._refreshAttempts + end + return + end + + -- Pop only if this answers the head; command answers arrive mid-load + 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 + + local wasHidden = field.hidden + -- Strict: reuse the name unless flagged stale. Passive: only folder + -- names embed values + 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 + -- Command finished: re-read what it may have changed. Only the active + -- command; idle command fields also load while browsing + self:_reloadRelated(field) + self.command = nil + self._pendingFrame = nil + end + + -- Only off our own read; siblings see every root answer + 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.command then + self._nextQueryAt = now + (self.command.timeout or 100) + end + if self._loadQueue[1] then + self._nextReadAt = 0 + elseif self._preloadArmed and self:isFolderLoaded(nil) then + self._preloadArmed = nil + self:preloadAll() + end +end + +-- ============================================================================ +-- Store queries +-- ============================================================================ + +function CRSFSession:getDevice(id) + for _, device in ipairs(self.devices) do + if device.id == id then + return device + end + end +end + +--- 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 + +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 + +--- Returns loaded, total; nil while the children are unknown. +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 + +function CRSFSession:isLoading() + return self._loadQueue[1] ~= nil +end + +function CRSFSession:isReceivingChunks() + return self.rx.chunk > 0 +end + +function CRSFSession:isWriting() + return self._writeHead <= self._writeTail +end + +-- ============================================================================ +-- Loading +-- ============================================================================ + +function CRSFSession:reloadAll() + self._fields = {} + self._loadQueue = { 0 } + self._nextReadAt = 0 + self._preloadArmed = self._preload + self._preloading = nil + params.resetChunks(self.rx) +end + +function CRSFSession:reloadField(field) + self._nextReadAt = 0 + params.resetChunks(self.rx) + self._loadQueue[#self._loadQueue + 1] = field.id +end + +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 + +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 + +--- One-off re-read after delay ticks, attempts default 3; not for polling. +function CRSFSession:refreshField(fieldId, delay, attempts) + self._refreshId = fieldId + self._refreshAt = getTime() + (delay or 0) + self._refreshAttempts = attempts or 3 + self._refreshLeft = self._refreshAttempts + params.resetChunks(self.rx) -- don't inherit an interrupted read's chunks +end + +-- ============================================================================ +-- Writing +-- ============================================================================ + +-- Parent name may embed values; siblings can hide or change options +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 + or siblingType == crsf.CONST.FIELD_COMMAND + 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 + +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 + +--- Each push replaces an RC-channels frame, so writes are paced. +--- Strict sessions re-read related fields; passive ones must 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 +-- ============================================================================ + +-- The radio's one Lua outbound slot may be busy; tick() retries +function CRSFSession:_sendStep(fieldId, step) + local frameType, payload = params.encodeCommandStep(self.deviceId, self.handsetId, fieldId, step) + if crsf.push(frameType, payload) then + self._pendingFrame = nil + self:_onStepSent(step) + else + self._pendingFrame = payload -- a newer step (e.g. cancel) replaces an unsent one + end +end + +-- Device restarts its answer at chunk 0 unless we are mid-entry. The reset +-- also clears rx.done, or a 1-chunk CMD_IDLE after a 2-chunk EXECUTING is +-- dropped as a duplicate +function CRSFSession:_onStepSent(step) + if step ~= crsf.CONST.CMD_QUERY or self.rx.chunk == 0 then + params.resetChunks(self.rx) + end +end + +function CRSFSession:execCommand(field) + -- self.command is the guard; field.status is stale after a cancel + if self.command or field.status == nil then + return + end + field.status = crsf.CONST.CMD_CLICK + self.command = field + self.commandAt = getTime() + self:_sendStep(field.id, crsf.CONST.CMD_CLICK) + -- Re-query, never re-click: that would run Bind twice + self._nextQueryAt = self.commandAt + self:_responseTimeout() +end + +function CRSFSession:confirmCommand() + if self.command then + self.command.status = crsf.CONST.CMD_CONFIRMED + self:_sendStep(self.command.id, crsf.CONST.CMD_CONFIRMED) + self._nextQueryAt = getTime() + self:_responseTimeout() + end +end + +function CRSFSession:cancelCommand() + if self.command then + self:_sendStep(self.command.id, crsf.CONST.CMD_CANCEL) + self.command = nil + end +end + +--- Keeps the popup until the device reports CMD_IDLE. +function CRSFSession:requestCancelCommand() + if self.command then + self:_sendStep(self.command.id, crsf.CONST.CMD_CANCEL) + self._nextQueryAt = getTime() + CANCEL_GRACE + end +end + +-- ============================================================================ +-- Status +-- ============================================================================ + +function CRSFSession:suppressCriticalErrors() + self.status.flags = 0 + crsf.push(params.encodeSuppressCriticalErrors(self.deviceId, self.handsetId)) +end + +-- ============================================================================ +-- Scheduler +-- ============================================================================ + +--- At most one frame per call: parked step > command > writes > status > +--- reads. +function CRSFSession:tick() + local now = getTime() + + if self._discovery then + -- Device may have changed + local connected = self.status.connected + if connected and not self._hadTelemetry then + crsf:pingDevices() + end + self._hadTelemetry = connected + if #self.devices == 0 and now > self._nextPingAt then + crsf:pingDevices() + self._nextPingAt = now + PING_PERIOD + end + end + + -- Even with no live command: a cancel must still go out + if self._pendingFrame then + if crsf.push(crsf.CONST.FRAMETYPE_PARAMETER_WRITE, self._pendingFrame) then + local step = self._pendingFrame[4] + self._pendingFrame = nil + self:_onStepSent(step) + end + return + end + + if self.command then + if now > self._nextQueryAt and self.command.status ~= crsf.CONST.CMD_ASKCONFIRM then + self:_sendStep(self.command.id, crsf.CONST.CMD_QUERY) + self._nextQueryAt = now + (self.command.timeout or 100) + end + return -- reads starve while a command runs, by design + 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 + -- Hardcodes ADDRESS_TX, which isElrsTx implies + 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 CRSFSession diff --git a/src/SCRIPTS/ELRS/defer.lua b/src/SCRIPTS/ELRS/defer.lua new file mode 100644 index 0000000..766edf4 --- /dev/null +++ b/src/SCRIPTS/ELRS/defer.lua @@ -0,0 +1,37 @@ +--------------------------------------------------------------------------- +-- Deferred Callback Timer -- +-- One pending callback; a new one replaces it. poll() once per run(). -- +-- Each loadScript() gives a private slot. -- +--------------------------------------------------------------------------- + +local Defer = { + _cb = nil, +} + +--- Calls fn(ctx) after ticks (10 ms units). +function Defer.setTimeout(ticks, fn, ctx) + Defer._cb = { + start = getTime(), + ticks = ticks, + fn = fn, + ctx = ctx, + } +end + +function Defer.clear() + Defer._cb = nil +end + +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 may schedule a successor + 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..d7fddce --- /dev/null +++ b/src/SCRIPTS/ELRS/edgetx_version.lua @@ -0,0 +1,27 @@ +--------------------------------------------------------------------------- +-- EdgeTX Version Gate -- +-- Keep edgetx.yml min_edgetx_version in step. -- +--------------------------------------------------------------------------- + +-- Keep in step with isSupported +local REQUIRED_VERSIONS = { + "- 2.11.6 or later", + "- 2.12.1 or later", + "- 3.0 or later", +} + +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, REQUIRED_VERSIONS diff --git a/src/SCRIPTS/ELRS/file_storage.lua b/src/SCRIPTS/ELRS/file_storage.lua new file mode 100644 index 0000000..6a686a9 --- /dev/null +++ b/src/SCRIPTS/ELRS/file_storage.lua @@ -0,0 +1,68 @@ +--------------------------------------------------------------------------- +-- Key=Value File Storage -- +--------------------------------------------------------------------------- + +local shim = loadScript("/SCRIPTS/ELRS/shim.lua")() + +local FileStorage = {} + +local READ_MAX = 512 -- caps a corrupt file + +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 + +--- 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 + 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 + +--- Writes keys in order, skipping absent values; nil when the file cannot be opened +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..2e534e8 --- /dev/null +++ b/src/SCRIPTS/ELRS/loader.lua @@ -0,0 +1,16 @@ +--------------------------------------------------------------------------- +-- GC-Guarded Script Loader -- +-- Collects before each load: without it first-run compiles run B&W -- +-- radios out of memory. -- +--------------------------------------------------------------------------- + +local function loader(path, ...) + collectgarbage("collect") + local chunk = loadScript(path) + if chunk == nil then + error(path) -- names the file on the error screen + 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..bd37b74 --- /dev/null +++ b/src/SCRIPTS/ELRS/msp.lua @@ -0,0 +1,157 @@ +--------------------------------------------------------------------------- +-- MSP-over-CRSF Codec -- +-- Loaded via loadScript() with (crsf); returns the Msp table. -- +-- -- +-- MSP frames tunnelled in CRSF. Single-frame v1 only: no chunking and -- +-- no CRC (RxTxEndpoint.cpp reads the payload by offset and checks none) -- +--------------------------------------------------------------------------- + +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, +} + +-- v1, start-of-frame, sequence 0 +local HEADER = Msp.CONST.MSP_VERSION_V1 + Msp.CONST.MSP_STARTFLAG + +local VERSION_MASK = 0x60 + +--- dest, src, header, size (bytes after fn), fn, 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 a single-frame v1 MSP_RESP addressed to the handset. The caller +-- gates on the source. +-- @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]. +-- @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..b15b8af --- /dev/null +++ b/src/SCRIPTS/ELRS/sensors.lua @@ -0,0 +1,28 @@ +--------------------------------------------------------------------------- +-- Telemetry Sensor Reader -- +--------------------------------------------------------------------------- + +local Sensors = {} + +Sensors._vCache = {} + +--- Call on model change: field IDs are per-model sensor slots. +function Sensors.resetCache() + Sensors._vCache = {} +end + +--- Unknown sensors are retried each call until discovered. +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..b46c031 --- /dev/null +++ b/src/SCRIPTS/ELRS/shim.lua @@ -0,0 +1,28 @@ +--------------------------------------------------------------------------- +-- B&W Compatibility Layer -- +-- B&W radios have no table library. -- +--------------------------------------------------------------------------- + +local shim = {} + +if table and table.concat then + shim.tableConcat = table.concat +else + shim.tableConcat = function(t, sep, i, j) + i = i or 1 + j = j or #t + if i > j then + return "" + end + local r = t[i] or "" + for k = i + 1, j do + if sep then + r = r .. sep + end + r = r .. (t[k] or "") + end + return r + end +end + +return shim diff --git a/src/SCRIPTS/ELRS/ui/lcd/dialogs.lua b/src/SCRIPTS/ELRS/ui/lcd/dialogs.lua new file mode 100644 index 0000000..85f0904 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/dialogs.lua @@ -0,0 +1,49 @@ +--------------------------------------------------------------------------- +-- B&W Dialogs -- +--------------------------------------------------------------------------- + +local Dialogs = {} + +local TEXT_H = 8 + +--- actions: optional { left, right } labels +function Dialogs.draw(title, lines, actions) + lcd.clear() + local y = 0 + lcd.drawText(2, y, title, MIDSIZE) + y = y + (TEXT_H * 2) - 2 + for _, line in ipairs(lines) do + lcd.drawText(2, y, line) + 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 + +function Dialogs.drawVersionRequired(versions) + local lines = { "Requires EdgeTX:" } + for i, version in ipairs(versions) do + lines[i + 1] = version + end + Dialogs.draw("Unsupported", lines) +end + +function Dialogs.drawNoModule() + Dialogs.draw(" 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 + +return Dialogs 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..06c555d --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lcd/text_edit.lua @@ -0,0 +1,169 @@ +--------------------------------------------------------------------------- +-- B&W Text Editor -- +-- Port of the firmware's editName() (stdlcd/draw_functions.cpp). -- +-- Edits apply live; EXIT commits too. Trailing spaces are stripped. -- +--------------------------------------------------------------------------- + +local TextEdit = {} +TextEdit.__index = TextEdit + +-- Firmware nameChars; digits and ',' let a raw UID be typed +local CHARS = " abcdefghijklmnopqrstuvwxyz0123456789_-,." + +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) + 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 + +--- visible: chars that fit the row +function TextEdit.new(maxLen, visible) + return setmetatable({ + maxLen = maxLen, + visible = visible, + value = "", + editing = nil, + cur = 1, + }, TextEdit) +end + +function TextEdit:start() + self.editing = true + self.cur = 1 + self.long = nil +end + +function TextEdit:_padded() + local v = self.value + while #v < self.cur - 1 do + v = v .. " " + end + return v +end + +function TextEdit:_setChar(c) + local v = self:_padded() + self.value = string.sub(v, 1, self.cur - 1) .. c .. string.sub(v, self.cur + 1) +end + +function TextEdit:_toggleCase() + local c = self:_charAt(self.cur) + if isUpper(c) then + self:_setChar(string.char(string.byte(c) + 32)) + elseif isLower(c) then + self:_setChar(string.char(string.byte(c) - 32)) + end +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 + +--- true when the edit committed +function TextEdit:handleEvent(event) + local c = self:_charAt(self.cur) + + -- Long ENTER is followed by a break Lua cannot kill; act on the break + local long = self.long + if event == EVT_VIRTUAL_ENTER_LONG then + self.long = true + return + elseif event == EVT_VIRTUAL_ENTER then + self.long = nil + end + + if + event == EVT_VIRTUAL_EXIT + or (event == EVT_VIRTUAL_ENTER and long and c == " ") + or (event == EVT_VIRTUAL_ENTER and not long and self.cur >= self.maxLen) + then + self:_commit() + return true + end + + if event == EVT_VIRTUAL_ENTER and long then + self:_toggleCase() + elseif 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) + elseif event == EVT_VIRTUAL_ENTER then + self.cur = self.cur + 1 + elseif event == EVT_VIRTUAL_NEXT_PAGE then + if self.cur <= #self.value and self.cur < self.maxLen then + self.cur = self.cur + 1 + end + elseif event == EVT_VIRTUAL_PREV_PAGE then + self.cur = math.max(self.cur - 1, 1) + end +end + +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 + + -- Keep the cursor on screen + local first = 1 + if self.visible and self.cur > self.visible then + first = self.cur - self.visible + 1 + end + local v = self:_padded() + local prefix = string.sub(v, first, self.cur - 1) + local suffix = "" + if self.visible then + suffix = string.sub(v, self.cur + 1, first + self.visible - 1) + else + suffix = string.sub(v, 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..193ebd3 --- /dev/null +++ b/src/SCRIPTS/ELRS/ui/lvgl/dialogs.lua @@ -0,0 +1,91 @@ +--------------------------------------------------------------------------- +-- Color LCD Dialogs -- +--------------------------------------------------------------------------- + +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 + +-- lines: label descriptors { text, font }, used 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 + +function Dialogs.showVersionRequired(versions, onExit) + local lines = { { type = lvgl.LABEL, text = "Requires EdgeTX:" } } + for i, version in ipairs(versions) do + lines[i + 1] = { type = lvgl.LABEL, text = version } + end + return buildExitDialog("EdgeTX Version Not Supported", lines, onExit) +end + +function Dialogs.noModuleChecklist(color) + return { + { type = lvgl.LABEL, color = color, text = "- Internal/External module enabled" }, + { + type = lvgl.LABEL, + color = color, + font = SMLSIZE, + text = " Internal: set Internal RF type to CRSF in SYS > Hardware", + }, + { type = lvgl.LABEL, color = color, text = "- Protocol set to CRSF" }, + { type = lvgl.LABEL, color = color, text = "- Suggested baud rate (depends on packet rate):" }, + { type = lvgl.LABEL, color = color, font = SMLSIZE, text = " 400k for 250Hz, 921k for 500Hz, 1.87M for 1000Hz" }, + } +end + +function Dialogs.showNoModule(onExit) + return buildExitDialog("No Module Found: Check Model Settings", Dialogs.noModuleChecklist(), 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..f98c326 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/main.lua @@ -0,0 +1,169 @@ +-- 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) + +---@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 isVersionSupported, requiredVersions = loader("/SCRIPTS/ELRS/edgetx_version.lua") +local versionOk = isVersionSupported() + +local App = { + -- Synthetic row types; above 0x7f so no wire field type collides + 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 + +function App.loadDevice(device) + if session:setDevice(device) then + Navigation.reset() + return true + end + return false +end + +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 + +function App.enterFolder(folderId, folderName, viewState) + Navigation.openFolder(folderId, folderName, viewState) + session:loadFolder(folderId) +end + +function App.goBack() + return Navigation.goBack() +end + +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 = 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, +}) + +local M = {} + +local function init() + local deps = { + App = App, + Navigation = Navigation, + session = session, + crsf = crsf, + VERSION = VERSION, + versionOk = versionOk, + requiredVersions = requiredVersions, + } + 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() + -- Returned table stays on the Lua stack; don't pin init's upvalues + M.init = nil +end + +local function run(event, touchState) + if event == nil then + return 2 + end + + if UI.preCheck then + local result = UI.preCheck(event) + if result ~= nil then + return result + end + 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 + +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..e31cfa1 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/navigation.lua @@ -0,0 +1,73 @@ +---- ######################################################################### +---- # Navigation Module: Folder navigation stack and methods # +---- ######################################################################### + +local Navigation = { + stack = {}, + TYPE_FOLDER = 0, -- ints, not strings: saves RAM on B&W + TYPE_DEVICE = 1, + FOLDER_OTHER_DEVICES = -1, +} + +function Navigation.getCurrent() + local top = Navigation.stack[#Navigation.stack] + return top and top.id or nil +end + +function Navigation.isAtRoot() + return #Navigation.stack == 0 +end + +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 (e.g. cursor) is restored 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..c806350 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lcd.lua @@ -0,0 +1,504 @@ +---- ######################################################################### +---- # BW LCD UI: Rendering, input handling, cursor management # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local Navigation = deps.Navigation +local session = deps.session +local crsf = deps.crsf +local VERSION = deps.VERSION + +local Dialogs = loadScript("/SCRIPTS/ELRS/ui/lcd/dialogs.lua")() + +-- 10 ms ticks; also paces the idle repaint +local WARN_FLASH_PERIOD = 100 + +local UI = { + lineIndex = 1, + pageOffset = 0, + edit = nil, + + visibleFields = nil, + + -- 128x64; UI.init adjusts for 212 wide / 96 tall + COL1 = 0, + COL2 = 70, + maxLineIndex = 6, + textSize = 8, + textYoffset = 3, + + forceRedraw = true, + folderWasReady = false, + wasLoading = false, + + titleShowWarn = nil, + titleShowWarnTimeout = 0, + titleWarnFlags = nil, + + warningDismissedAt = nil, + + commandRunningIndicator = 1, +} + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + end + if LCD_H == 96 then + UI.maxLineIndex = 9 + end +end + +function UI.preCheck(event) + if not deps.versionOk then + Dialogs.drawVersionRequired(deps.requiredVersions) + if event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return 2 + end + return 0 + end + if not App.checkCrsfModule() then + Dialogs.drawNoModule() + return 0 + end + return nil +end + +-- Keeps the cursor; onDeviceLoaded() resets it +function UI.invalidate() + UI.forceRedraw = true + UI.visibleFields = nil +end + +function UI.onDeviceLoaded() + UI.lineIndex = 1 + UI.pageOffset = 0 + UI.invalidate() +end + +function UI.onNewDevice() + UI.invalidate() +end + +function UI.handleUnsupported() + Dialogs.draw("Unsupported", { + "ELRS 1.x firmware", + "detected. Update to", + "3.5.4 or later.", + }) +end + +function UI.render(event, _touchState) + -- Flags change restarts the flash on its visible half + 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 + + if UI.warningDismissedAt and time - UI.warningDismissedAt > 6000 then -- re-show 60 s after dismiss + UI.warningDismissedAt = nil + end + + 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 + Dialogs.draw("Model Mismatch", { + "RX connected but", + "Model ID doesn't match.", + "Toggle Model Match", + "to re-sync", + }, { left = "[OK]", right = "[RTN] Change model" }) + return + end + + -- Redraw once more after the queue empties, or the last response never shows + local loading = session:isLoading() + if loading or UI.wasLoading then + UI.forceRedraw = true + end + UI.wasLoading = loading + + 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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +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 + +-- Precomputed: no table.concat on BW radios +local SENDING_FRAMES = { "Sending... [|]", "Sending... [/]", "Sending... [-]", "Sending... [\\]" } +-- 200 ms: a healthy link answers first, so nothing flashes +local PENDING_POPUP_DELAY = 20 + +function UI.drawPopup(event) + local command = session.command + local status = command.status + if 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() + UI.invalidate() -- nothing else repaints once the popup drops + end + elseif 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() + UI.invalidate() + end + else + -- Awaiting reply; EXIT cancels, popup tracks until CMD_IDLE + if event == EVT_VIRTUAL_EXIT then + session:requestCancelCommand() + end + if getTime() - session.commandAt >= PENDING_POPUP_DELAY then + if not session:isReceivingChunks() then + UI.commandRunningIndicator = (UI.commandRunningIndicator % 4) + 1 + end + popupConfirmation(SENDING_FRAMES[UI.commandRunningIndicator], "Press [RTN] to cancel", event) + 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..7721b6b --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRS/ui/lvgl.lua @@ -0,0 +1,918 @@ +---- ######################################################################### +---- # 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 Dialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + +local UI = { + currentPage = nil, + uiBuilt = false, + folderWasReady = false, + + -- Page kind shown; rebuild only when it changes + warningDismissedAt = nil, + warningDialog = nil, + commandDialog = nil, + commandPage = nil, +} + +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 + +local function exitTool() + App.shouldExit = true +end + +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, + text = getInfo, -- getter: shows each CMD_QUERY reply + }, + { + 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, + }, + { + type = lvgl.LABEL, + w = lvgl.PERCENT_SIZE + 100, + align = CENTER, + font = BOLD, + text = getInfo, -- getter: shows each CMD_QUERY reply + }, + { + 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 + +function UI.init() end + +function UI.preCheck(_event) + if deps.versionOk and App.checkCrsfModule() then + return nil + end + if not UI.uiBuilt then + if deps.versionOk then + Dialogs.showNoModule(exitTool) + else + Dialogs.showVersionRequired(deps.requiredVersions, exitTool) + end + UI.uiBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 +end + +function UI.invalidate() + UI.uiBuilt = false +end + +function UI.onDeviceLoaded() + UI.invalidate() +end + +function UI.onNewDevice() + if Navigation.getCurrent() == Navigation.FOLDER_OTHER_DEVICES or UI.folderWasReady then + UI.invalidate() + end +end + +function UI.handleUnsupported() + if not UI.uiBuilt then + Dialogs.showMessage({ + title = "Unsupported Firmware", + message = "ELRS 1.x firmware detected. Please update to 3.5.4 or later.", + }) + UI.uiBuilt = true + end +end + +function UI.openFolder(folderId, folderName) + -- Strip the value suffix, 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 + +-- CLICK, CONFIRMED and EXECUTING share one page +local PAGE_CONFIRM = 1 +local PAGE_EXECUTING = 2 +-- 200 ms: a healthy link answers first, so nothing flashes +local PENDING_PAGE_DELAY = 20 + +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.commandPage = nil + return + end + + local status = command.status + local kind = PAGE_EXECUTING + if status == crsf.CONST.CMD_ASKCONFIRM then + kind = PAGE_CONFIRM + elseif status == crsf.CONST.CMD_CLICK and getTime() - session.commandAt < PENDING_PAGE_DELAY then + return + end + if kind == UI.commandPage then + return + end + UI.commandPage = kind + + if kind == PAGE_CONFIRM then + UI.commandDialog = CommandPage.showConfirm(command.name, function() + return command.info or "" + end, function() + session:confirmCommand() + end, onCommandCancel) + else + UI.commandDialog = CommandPage.showExecuting(command.name, function() + if command.status == crsf.CONST.CMD_EXECUTING then + return command.info or "" + end + return "Sending..." + end, onCommandCancel) + end +end + +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 + +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 + +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 + +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 + +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 + -- values is refilled in place; valuesRev marks changes + 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 + +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..0842894 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/history_storage.lua @@ -0,0 +1,81 @@ +--------------------------------------------------------------------------- +-- Bind Phrase History Storage -- +-- Newest first, as keys h1..hN. MAX * 56 bytes must fit FileStorage's -- +-- 512-byte read. No table library on B&W, so shifts are loops. -- +--------------------------------------------------------------------------- + +local FileStorage = ... + +local PATH = "/SCRIPTS/TOOLS/ExpressLRSBind/history.txt" + +local MAX = 5 + +local SAVE_KEYS = {} +for i = 1, MAX do + SAVE_KEYS[i] = "h" .. i +end + +local History = { + MAX = MAX, + 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 + +-- A re-used phrase moves to the front +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 + +-- First missing key ends the list +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..d831bc1 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/main.lua @@ -0,0 +1,292 @@ +-- TNS|ExpressLRS Bind|TNE +---- ######################################################################### +---- # ExpressLRS bind phrase manager; needs ExpressLRS 4.1+ (MSP) # +---- # License GPLv2: http://www.gnu.org/licenses/gpl-2.0.html # +---- ######################################################################### + +local VERSION = "r1" +local useLvgl = (lvgl ~= nil) + +---@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 isVersionSupported, requiredVersions = loader("/SCRIPTS/ELRS/edgetx_version.lua") +local versionOk = isVersionSupported() + +-- getTime() ticks (10 ms) +local UID_RETRY_TICKS = 50 +local UID_MAX_ATTEMPTS = 12 -- 6 s, outlasts a module reboot +local FOLLOWUP_TICKS = 100 -- settle after a write + +local App = { + -- Order matches the UIs' choice lists + TARGET_TX = 1, + TARGET_RX = 2, + TARGET_BOTH = 3, + + target = 1, + phrase = "", + bothStep = nil, -- TARGET_RX during Both's RX write + uid = {}, -- filled by msp.decodeUid + uidFrom = nil, + statusText = "Idle", -- nil shows the UID + uidAttempts = 0, + -- Bump only for build-time snapshots; a rebuild resets focus + 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 + +-- RX only answers over a live 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 + +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 + +-- Decimal byte, spaces trimmed; no string patterns on B&W +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 + +-- 4-6 comma-separated bytes, zero-padded to 6; nil for 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 + +-- Bounded: pre-4.1 devices never answer +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 + +function App.startUidRequest() + App.uidAttempts = 0 + App.requestUid() +end + +-- Both: RX first, as the write drops it off the link, then TX +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 + -- CHOICE shows its build-time value; 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 + +function App.sendBind() + App.statusText = "Sending bind command..." + crsf:sendBindCommand(crsf.CONST.ADDRESS_TX) + defer.setTimeout(FOLLOWUP_TICKS, markSent) +end + +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 + +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 + +local UI + +-- Forward-declared so init() can drop itself +local M = {} + +local function init() + local deps = { + App = App, + crsf = crsf, + msp = msp, + VERSION = VERSION, + versionOk = versionOk, + requiredVersions = requiredVersions, + } + 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() + -- Wait for run()'s first drain to create the telemetry queue + defer.setTimeout(1, App.startUidRequest) + -- The returned table stays on the Lua stack and would pin init() + M.init = nil +end + +local function run(event, touchState) + if event == nil then + return 2 + end + + if UI.preCheck then + local result = UI.preCheck(event) + if result ~= nil then + return result + end + end + + crsf.drain(App, App.onFrame) + -- After drain, so a push is answered before its follow-up + defer.poll() + + UI.render(event, touchState) + + if App.shouldExit then + return 2 + end + return 0 +end + +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..903ef62 --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lcd.lua @@ -0,0 +1,296 @@ +---- ######################################################################### +---- # Bind tool UI for black & white radios # +---- ######################################################################### + +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 Dialogs = loadScript("/SCRIPTS/ELRS/ui/lcd/dialogs.lua")() + +-- Numeric ids save RAM; history rows are HIST_BASE + i +local ROW_PHRASE = 1 +local ROW_TARGET = 2 +local ROW_UID = 3 +local ROW_SET = 4 +local ROW_BIND = 5 -- Bind or Unbind, by link state +local ROW_HISTORY = 6 +local ROW_EXIT = 7 +local ROW_CLEAR = 8 +local ROW_BACK = 9 +local HIST_BASE = 100 + +local EXIT_LABEL = "[-- EXIT (" .. VERSION .. ") --]" + +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 = { + page = PAGE_MAIN, + lineIndex = 1, + rows = {}, + editTarget = nil, + ---@type table constructed in init(), needs LCD_W for the window + phraseEdit = nil, + + -- { msg, i = history index, or nil to clear all } + confirm = nil, + + -- 128x64; init widens COL2 on 212 px + COL1 = 0, + COL2 = 70, + textSize = 8, + textYoffset = 3, + + forceRedraw = true, + lastStatus = nil, +} + +-- Both pages fit 8 rows on 128x64: no scrolling + +function UI.init() + if LCD_W == 212 then + UI.COL2 = 110 + end + + -- 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 + +function UI.preCheck(event) + if not deps.versionOk then + Dialogs.drawVersionRequired(deps.requiredVersions) + if event == EVT_VIRTUAL_EXIT then + App.shouldExit = true + return 2 + end + return 0 + end + if not App.checkCrsfModule() then + Dialogs.drawNoModule() + return 0 + end + return nil +end + +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 + +-- Compact to fit 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 + +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) + if UI.phraseEdit.editing then + if UI.phraseEdit:handleEvent(event) then + App.phrase = UI.phraseEdit.value + end + return + end + + 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 + +local function drawTitle() + lcd.drawFilledRectangle(0, 0, LCD_W, UI.textSize + 1, GREY_DEFAULT) + lcd.drawText(UI.COL1 + 1, 1, "ExpressLRS Bind Tool", INVERS) + -- C while RX telemetry is alive + 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_LABEL, 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() + + -- No scrolling: each page is at most 7 rows + for i = 1, #UI.rows do + drawRow(UI.rows[i], i * UI.textSize + UI.textYoffset, UI.lineIndex == i) + end +end + +function UI.render(event, _touchState) + -- Arm after a quiet frame, or the long press's ENTER release answers it + 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 + + 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..2a88a0e --- /dev/null +++ b/src/SCRIPTS/TOOLS/ExpressLRSBind/ui/lvgl.lua @@ -0,0 +1,215 @@ +---- ######################################################################### +---- # Bind tool UI for colour LVGL radios # +---- ######################################################################### + +local deps = ... + +local App = deps.App +local crsf = deps.crsf +local msp = deps.msp + +local Dialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + +local UI = { + -- App.rev the page was built for + builtRev = nil, + dialogBuilt = false, +} + +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 Tool", + subtitle = App.uidLine, + back = exitTool, + }) + + local tbox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + }) + + 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, + -- 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, + }, + }, + }, + }, + }) + + 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, + }, + }, + }, + }, + }) + + 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, + }, + }, + }) + + 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, + }) + row:button({ + w = lvgl.PERCENT_SIZE + 80, + text = function() + return App.history.items[i] or "" + end, + press = function() + App.useHistory(i) + end, + }) + row:button({ + text = "X", + textColor = COLOR_THEME_WARNING, + press = function() + App.removeHistory(i) + end, + }) + end +end + +function UI.init() end + +function UI.preCheck(_event) + if deps.versionOk and App.checkCrsfModule() then + return nil + end + if not UI.dialogBuilt then + if deps.versionOk then + Dialogs.showNoModule(exitTool) + else + Dialogs.showVersionRequired(deps.requiredVersions, exitTool) + end + UI.dialogBuilt = true + end + if App.shouldExit then + return 2 + end + return 0 +end + +-- TEXT_EDIT's value is a build-time snapshot; rebuild on App.rev +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..8f695bc --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/loadable.lua @@ -0,0 +1,92 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Wiring -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with -- +-- (zone, options, Telemetry); returns the widget instance table. -- +--------------------------------------------------------------------------- + +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 +-- ============================================================================ + +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() per instance (each owns a pop queue); update() runs once per tick +function wgt.background() + Telemetry.drain() + Telemetry.update() +end + +function wgt.refresh(_event, _touchState) + wgt.background() +end + +local FullScreenUI + +-- Loaded lazily; only one widget can be full screen +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 + +-- Label callbacks can fire before the first refresh() +Telemetry.update() + +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..14bdf45 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/main.lua @@ -0,0 +1,58 @@ +--------------------------------------------------------------------------- +-- 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 + -- Singleton survives model changes; reset on a new model, not on every create() + local modelId = model.getInfo().name + if _elrsTelemetrySingleton.modelId ~= modelId then + _elrsTelemetrySingleton.modelId = modelId + _elrsTelemetrySingleton.resetModel() + end + _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..2d51797 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/rf_modes.lua @@ -0,0 +1,190 @@ +--------------------------------------------------------------------------- +-- ELRS RF Mode Tables -- +-- Loaded via loadScript() from ELRSTelemetry/telemetry.lua with no -- +-- arguments; returns the RfModes table. -- +-- -- +-- Packet-rate names and sensitivity floors per ELRS major version. -- +-- RFMD is 0-based; callers pass the raw sensor value. -- +--------------------------------------------------------------------------- + +local RfModes = {} + +-- nil until a module answers a device ping +---@type table? +RfModes._names = nil +---@type table? +RfModes._floors = nil + +---@type number? +RfModes._maj = nil + +-- ============================================================================ +-- Selection +-- ============================================================================ + +-- Newer firmware falls back to the newest known table +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 +-- ============================================================================ + +function RfModes.name(rfmd) + local names = RfModes._names + return (names and names[rfmd + 1]) or table.concat({ "RFMD", tostring(rfmd) }) +end + +function RfModes.floor(rfmd) + local floors = RfModes._floors + local dbm = floors and floors[rfmd + 1] + -- 0 = unrated; nil so callers' "or" defaults apply + if dbm == 0 then + return nil + end + return dbm +end + +-- FLRC rates report no SNR +local NO_SNR = { D250 = true, D500 = true, F500 = true, F1000 = true } + +function RfModes.hasSnr(rfmd) + local names = RfModes._names + local name = names and names[rfmd + 1] + return not (name and NO_SNR[name]) +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..31893d8 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/telemetry.lua @@ -0,0 +1,369 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry State -- +-- Loaded via loadScript() from ELRSTelemetry/main.lua with (crsf); -- +-- returns the Telemetry singleton. -- +-- -- +-- Shared by all widget instances. Each instance gets its own copy of -- +-- every frame, so frame handlers must only assign, never count. -- +--------------------------------------------------------------------------- + +local crsf = ... + +local RfModes = loadScript("/WIDGETS/ELRSTelemetry/rf_modes.lua")() + +local Telemetry = {} + +-- ============================================================================ +-- State +-- ============================================================================ + +-- Refilled in place each tick; safe to cache +Telemetry.link = {} + +---@type number? +Telemetry.headroomPct = nil + +---@type number? +Telemetry.cellCnt = nil + +-- Kept across dropouts to find a lost model +---@type {lat: number, lon: number}|nil +Telemetry.gps = nil + +---@type boolean? +Telemetry.modelMismatch = nil + +---@type string? +Telemetry.modelId = nil + +---@type number? +Telemetry._cellCntCnt = nil +Telemetry._cellLastV = nil + +---@type number? +Telemetry._smoothHead = nil + +Telemetry._device = {} + +Telemetry._lastDevPoll = 0 + +Telemetry._lastStatusPoll = 0 + +---@type boolean? +Telemetry._statusAnswered = nil + +Telemetry._wasConnected = false + +---@type number? +Telemetry._sampledAt = nil + +-- ============================================================================ +-- Queries +-- ============================================================================ + +function Telemetry.hasModule() + return crsf.hasCrsfModule() +end + +function Telemetry.isConnected() + return crsf.hasTelemetry +end + +-- Flag is stale once disconnected +function Telemetry.isMismatch() + return crsf.hasTelemetry and Telemetry.modelMismatch +end + +-- Worst first +Telemetry.STATUS = { + NO_MODULE = 1, + NO_TELEMETRY = 2, + MISMATCH = 3, + OK = 4, +} + +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 + +function Telemetry.activeRssi() + local link = Telemetry.link + return (link.ant == 1) and link.rssi2 or link.rssi1 +end + +-- dB above the RF mode's sensitivity floor +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 + +-- Single-antenna RX leaves 2RSS at 0 dBm +function Telemetry.hasDiversity() + local rssi2 = Telemetry.link.rssi2 + return rssi2 ~= nil and rssi2 ~= 0 +end + +function Telemetry.rfModeName() + local rfmd = Telemetry.link.rfmd + if rfmd == nil then + return nil + end + return RfModes.name(rfmd) +end + +function Telemetry.hasSnr() + local rfmd = Telemetry.link.rfmd + return rfmd == nil or RfModes.hasSnr(rfmd) +end + +-- ============================================================================ +-- Derived state +-- ============================================================================ + +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 + +-- RX saturates above this; headroom is 100% here +Telemetry.RSSI_CEILING = -50 +local RSSI_CEILING = Telemetry.RSSI_CEILING + +-- 0% = sensitivity floor, 100% = RSSI_CEILING +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 + 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 + +local function updateGps() + local gps = crsf.getSensorValue("GPS") + if gps and gps ~= 0 then + Telemetry.gps = gps + end +end + +-- ============================================================================ +-- Resets +-- ============================================================================ + +local function resetConnection() + Telemetry._smoothHead = nil + Telemetry.headroomPct = nil + Telemetry.cellCnt = nil + Telemetry._cellCntCnt = nil + Telemetry._cellLastV = nil +end + +function Telemetry.resetModelMatch() + Telemetry.modelMismatch = nil + Telemetry._statusAnswered = nil +end + +function Telemetry.resetModel() + resetConnection() + Telemetry.resetModelMatch() + Telemetry.gps = nil + Telemetry._wasConnected = false + Telemetry._sampledAt = nil -- a sibling may have sampled this tick + crsf.resetSensorCache() +end + +-- ============================================================================ +-- Outgoing requests +-- ============================================================================ + +-- EdgeTX's own init ping lands before the Lua queue exists +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 + +local MODEL_MATCH_MIN_RSSI = -70 + +-- Each request replaces one RC-channels frame on the UART +local function canRequestStatus(now) + if not crsf.hasTelemetry then + return false + end + -- TBS modules never answer this + if not Telemetry._device.isElrs then + return false + end + -- Keep polling while mismatched; a fix never drops the link + if Telemetry._statusAnswered and not Telemetry.modelMismatch then + return false + end + -- Don't steal RC frames at range + local rssi = Telemetry.activeRssi() + if rssi == nil or rssi <= MODEL_MATCH_MIN_RSSI then + return false + end + return now - Telemetry._lastStatusPoll >= 100 -- module refreshes at 1 Hz +end + +local function updateModelMatch() + local now = getTime() + if not canRequestStatus(now) then + return + end + Telemetry._lastStatusPoll = now + crsf:requestElrsStatus() +end + +-- ============================================================================ +-- Per-tick pump +-- ============================================================================ + +-- 30 ms: dedupes instances within one 50 ms widget loop; else smoothing +-- speeds up per widget +local SAMPLE_INTERVAL = 3 + +-- Call after drain() so hasTelemetry is current +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.rsnr = crsf.getSensorValue("RSNR") + 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") + 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 +-- ============================================================================ + +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 + +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 + +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 + +-- Every instance, every frame: each has its own queue that would overflow +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..7ef2167 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/components.lua @@ -0,0 +1,713 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Drawing Components -- +-- Functions append lvgl descriptors to a caller-owned list; each -- +-- layout calls lvgl.build() once. Widget zones only use label, -- +-- rectangle and circle: fullscreen-only types fail silently. -- +-- hline has no thickness. -- +--------------------------------------------------------------------------- + +local Display = ... + +local Components = {} + +local TRACK_OPACITY = 90 + +local BRAND = "ExpressLRS" +local BRAND_SHORT = "ELRS" + +local MISMATCH = "MODEL MISMATCH!" +local MISMATCH_SHORT = "MISMATCH!" + +-- Widest string of each signal form, reserved so the row does not reflow. +local SIGNAL_FULL = "-105 -105 / -105 dBm" +local SIGNAL_SHORT = "-105 / -105 dBm" + +local function antCell(m) + return math.floor(m.sml / 2) + 1 +end + +-- ============================================================================ +-- Metrics +-- ============================================================================ + +--- Font line heights. Measured, as 800x480 ships different fonts. +--- lcd.sizeText still works under LVGL (no LCD-buffer guard). +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, cached on m. +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 + +--- Panel fills the zone; padding matches the VTX Admin widget. +--- inner is relative to the panel. +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 + +--- Longest widget name that fits availW, or nil. +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 + +--- Largest font in the list that fits the budget, else the last one. +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 +-- ============================================================================ + +--- Fill plus a borderless container, so opacity does not fade the content. +--- Returns the container's child list. +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 + +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 + +--- Horizontal bar. spec.pct returns 0-100; spec.visible hides the fill. +function Components.bar(dst, rect, y, spec) + local h = spec.h + local w = rect.w + local r = math.floor(h / 2) + dst[#dst + 1] = { + type = lvgl.RECTANGLE, + x = rect.x, + y = y, + w = w, + h = h, + 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, + -- luaL_checkunsigned; 0 would be a real zero-width object + 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 + +--- LED in a fixed lane; returns the x where text starts. +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), + y = y + math.floor(m.sml / 2), + radius = math.floor(m.sml / 4) + 1, + color = Display.ledColor, + }) + return rect.x + lane + m.pad +end + +--- Status LED. x and y are the centre. +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 + +--- Antenna dots, the active one lit. +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, + x = cx + (i - 1) * (cell + gap) + r, + 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 +-- ============================================================================ + +--- LED, name, RF mode and antenna dots; a model mismatch banner replaces +--- all but the LED. Returns the next free y. +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 + + -- Widest rate name, so the row does not shift + local modeW = Components.textWidth(spec.detail and "K1000Full 2000mW" or "K1000Full", SMLSIZE) + local nameGap = m.pad * 2 + -- "Ant" caption only if the full name still fits + 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 + + local bannerLimit = rect.x + rect.w + local banner = MISMATCH + if Components.textWidth(banner, BOLD) > bannerLimit - textX then + banner = MISMATCH_SHORT + end + -- Still no room: hide the name during a mismatch + 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, + 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, + text = banner, + visible = Display.isMismatch, + }) + + Components.antenna(dst, { x = antX }, y, m) + return y + h +end + +--- LQ number with an "LQ %" caption; spec.right adds a small reading at the +--- right edge. +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, + }) + -- Baseline-align the caption; 4/5 as line boxes include descender room + 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 + +--- Caption on the left, value right-aligned. +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 + +--- Per column: widest caption and widest reserved value. +local function gridColumns(rows) + local cols = {} + for i = 1, #rows do + for j = 1, #rows[i] do + local cell = rows[i][j] + local col = cols[j] + if col == nil then + col = { caption = 0, value = 0 } + cols[j] = col + end + col.caption = math.max(col.caption, Components.textWidth(cell.caption, SMLSIZE)) + col.value = math.max(col.value, Components.textWidth(cell.reserve, SMLSIZE)) + end + end + return cols +end + +local function gridMinColW(m, rows) + local need = 0 + local cols = gridColumns(rows) + for j = 1, #cols do + need = math.max(need, cols[j].caption + m.pad + cols[j].value) + end + return need +end + +--- Two columns of { caption, text, reserve } cells, one per line when too +--- narrow. reserve is the widest string the value can render. +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 + local cols = gridColumns(rows) + 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 + cols[j].caption + 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 + +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 + +--- Widest signal form that fits availW. +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 + +-- ============================================================================ +-- Layouts by zone size +-- ============================================================================ + +--- Header, LQ, LQ bar, RSSI row and bar, reading grid. +function Components.fullTier(w, h, opa, m, spec) + local f = Components.frame(w, h, m) + local pad, inner = f.pad, f.inner + + 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 + -- Must match what the blocks below consume + local fixedNoHero = pad * 2 + + (m.sml + 4) -- header row + + m.gap -- header to LQ + + m.gap -- LQ 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 + local barH = math.max(MIN_BAR, math.min(math.floor(slack * 0.12), spec.barH)) + 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, + -- Needs a rated floor to scale + visible = Display.hasHeadroom, + }) + Components.grid(panel, inner, y + air, m, rows) + + lvgl.build(root) +end + +--- Header with power, LQ, both bars, and the BATT/TRSS row when it fits. +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 + local MIN_BAR = 3 + local fixed = pad * 2 + + (m.sml + 4) -- header row + + heroH + + m.gap -- LQ to LQ bar + + m.sml -- RSSI row + + m.gap -- RSSI row to its bar + + -- Drop the grid row before squeezing the bars + 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 + +--- Header, LQ with the signal on its baseline, RSSI bar. +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 + 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 }) + 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 + +--- 1/4 and 1/6 zones: one row (name, RF detail, LQ, signal) over the RSSI +--- bar. The LQ spot shows the status text when there is one. +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) + local MIN_BAR = 3 + local vpad = pad + local slack = f.h - (vpad * 2 + heroH) + if slack < MIN_BAR then + -- 1/6 zone: shrink vertical padding to keep a bar + vpad = m.gap + slack = f.h - (vpad * 2 + heroH) + end + 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) + -- Baseline-align small text with the LQ + local drop = math.max(0, heroH - m.sml) + + local root = {} + local panel = Components.panel(root, f.panel, { opacity = opa }) + -- Centre the row when there is no bar + local y = vpad + (barH > 0 and 0 or math.floor(air / 2)) + + 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) + -- Short signal rather than losing the name or the LQ + local gap = m.pad * 2 + local brandW = Components.textWidth(BRAND_SHORT, SMLSIZE) + gap + local signalW = Components.textWidth(SIGNAL_SHORT, SMLSIZE) + local signalText = Display.signalShortText + local avail = textRight - textX - signalW - heroW - gap + if avail < brandW then + local shortW = Components.textWidth("-105dBm", SMLSIZE) + local shortAvail = textRight - textX - shortW - heroW - gap + if shortAvail >= brandW or avail < 0 then + signalW = shortW + signalText = Display.rssiText + avail = shortAvail + end + end + local showDetail = avail >= detailW + gap + if showDetail then + avail = avail - detailW - gap + end + local brand = Components.brandText(avail - gap) + + if brand then + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = brand, + }) + textX = textX + Components.textWidth(brand, SMLSIZE) + gap + end + if showDetail then + Components.label(panel, { + x = textX, + y = y + drop, + font = SMLSIZE, + color = COLOR_THEME_SECONDARY1, + text = Display.rfDetailText, + visible = Display.isNotMismatch, + }) + textX = textX + detailW + gap + end + -- LQ and status swap in one spot; status is content-sized so it cannot wrap + local heroRight = textRight - signalW - gap + 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 + 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..d04c725 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/display.lua @@ -0,0 +1,335 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Display Components -- +-- Zero-arg LVGL text/color callbacks: dot functions, not methods. -- +--------------------------------------------------------------------------- + +local Telemetry = ... + +local Display = {} + +function Display.isConnected() + return Telemetry.isConnected() +end + +function Display.isMismatch() + return Telemetry.isMismatch() +end + +function Display.isNotMismatch() + return not Telemetry.isMismatch() +end + +function Display.hasDiversity() + return Telemetry.hasDiversity() +end + +local STATUS_TEXT = { + [Telemetry.STATUS.NO_MODULE] = "No CRSF module", + [Telemetry.STATUS.NO_TELEMETRY] = "No telemetry", + [Telemetry.STATUS.MISMATCH] = "Model Mismatch", +} + +--- nil when connected with no warnings. +function Display.statusText() + return STATUS_TEXT[Telemetry.statusLevel()] +end + +function Display.pageSubtitle() + return Display.statusText() or "Telemetry" +end + +--- e.g. "100%" +function Display.lqValueText() + if not Telemetry.isConnected() then + return "--" + end + return table.concat({ tostring(Telemetry.link.rqly or 0), "%" }) +end + +function Display.lqPct() + if not Telemetry.isConnected() then + return 0 + end + return Telemetry.link.rqly or 0 +end + +function Display.headroomPct() + return Telemetry.headroomPct or 0 +end + +function Display.hasHeadroom() + return Telemetry.headroomPct ~= nil +end + +--- e.g. "100 %" +function Display.tqlyText() + local tqly = Telemetry.link.tqly + if not Telemetry.isConnected() or tqly == nil then + return "--" + end + return table.concat({ tostring(tqly), " %" }) +end + +--- e.g. "-95 dBm" +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 + +--- e.g. "100 mW" +function Display.powerText() + local tpwr = Telemetry.link.tpwr + if not Telemetry.isConnected() or tpwr == nil then + return "--" + end + return table.concat({ tostring(tpwr), " mW" }) +end + +--- e.g. "4S 3.75 V" +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 + +--- e.g. "15.20 V" +function Display.packText() + local vbat = Telemetry.link.vbat + if vbat == nil or vbat <= 0 then + return "--" + end + return string.format("%.2f V", vbat) +end + +--- e.g. "LQ 100%" +function Display.lqText() + return table.concat({ "LQ ", tostring(Telemetry.link.rqly or 0), "%" }) +end + +--- e.g. "99" +function Display.lqHeroText() + if not Telemetry.isConnected() then + return "--" + end + return tostring(Telemetry.link.rqly or 0) +end + +--- e.g. "-87dBm", "" while unknown; sep goes between number and unit. +function Display.rssiText(sep) + local rssi = Telemetry.activeRssi() + if rssi == nil then + return "" + end + return table.concat({ tostring(rssi), sep or "", "dBm" }) +end + +function Display.hasStatus() + return Display.statusText() ~= nil +end + +function Display.noStatus() + return Display.statusText() == nil +end + +--- RSSI / sensitivity, e.g. "-85 -92 / -112 dBm" with diversity. +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 + +--- Active antenna only, e.g. "-90 / -112 dBm": the pair doesn't fit the +--- compact row. +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 + +--- e.g. "250Hz" +function Display.rfModeText() + if not Telemetry.isConnected() then + return "" + end + return Telemetry.rfModeName() or "" +end + +--- 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 + +--- 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 + +function Display.heroColor() + if Telemetry.isMismatch() then + return COLOR_THEME_WARNING + end + return COLOR_THEME_PRIMARY1 +end + +-- Theme colours follow the user's theme; GREEN/RED literals are raw primaries. +local HEALTH = { COLOR_THEME_EDIT, COLOR_THEME_ACTIVE, COLOR_THEME_WARNING } + +-- ACTIVE yellow is unreadable as text on light themes. +local HEALTH_TEXT = { COLOR_THEME_EDIT, ORANGE, COLOR_THEME_WARNING } + +-- dB above sensitivity. +local MARGIN_CRIT = 10 +local MARGIN_WARN = 30 + +--- level: 1 good, 2 warn, 3 critical. +function Display.healthColor(level) + return HEALTH[level] or COLOR_THEME_DISABLED +end + +function Display.healthTextColor(level) + return HEALTH_TEXT[level] or COLOR_THEME_DISABLED +end + +local LQ_GOOD = 90 +local LQ_CRIT = 50 + +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 + +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 + +function Display.lqBarColor() + return Display.healthColor(Display.lqLevel()) +end + +function Display.lqTextColor() + return Display.healthTextColor(Display.lqLevel()) +end + +function Display.headroomBarColor() + return Display.healthColor(Display.marginLevel()) +end + +-- Half-periods in 10 ms ticks: 1 Hz and 4 Hz. +local BLINK_SLOW = 50 +local BLINK_FAST = 12 + +local function lit(halfPeriod) + return math.floor(getTime() / halfPeriod) % 2 == 0 +end + +--- Blinks only when the link is broken or mismatched. +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 + +--- Factory, not a callback. +function Display.antColor(n) + return function() + if not Telemetry.isConnected() then + return COLOR_THEME_DISABLED + end + -- ANT is 0-based. + if (Telemetry.link.ant or 0) + 1 == n then + return COLOR_THEME_FOCUS -- not PRIMARY1: black on light themes + end + return COLOR_THEME_DISABLED + end +end + +--- Neutral when healthy or unknown; warns as the margin shrinks. +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 Display diff --git a/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua new file mode 100644 index 0000000..3abda9c --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/fullscreen.lua @@ -0,0 +1,275 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Full-Screen Page -- +-- Built on entry only; values refresh via callbacks. -- +--------------------------------------------------------------------------- + +local Telemetry, Display = ... + +local FullScreenUI = {} + +-- Narrower labels on portrait. +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +-- "--" while the link is down. +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 + +function FullScreenUI.build() + lvgl.clear() + + local pg = lvgl.page({ + title = "ExpressLRS", + subtitle = Display.pageSubtitle, + back = function() + lvgl.exitFullScreen() + end, + }) + + -- Rebuilt on re-entry, so checking once is enough. + if not Telemetry.hasModule() then + local Dialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + local heading = { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 } + local rows = Dialogs.noModuleChecklist(COLOR_THEME_DISABLED) + table.insert(rows, 1, heading) + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = rows, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + fields:build({ + { + type = lvgl.LABEL, + font = BOLD, + color = COLOR_THEME_WARNING, + text = "Model Mismatch — RC commands not sent", + visible = Display.isMismatch, + }, + }) + + createSectionHeader(fields, "Link Status") + + createDisplayRow(fields, "RF Mode", Display.rfModeText) + + createDisplayRow(fields, "Link Quality", Display.lqValueText) + + createDisplayRow( + fields, + "RSSI", + whenConnected(function() + local rssi1 = Telemetry.link.rssi1 + if rssi1 == nil then + return "--" + end + if Telemetry.hasDiversity() then + return table.concat({ tostring(rssi1), " / ", tostring(Telemetry.link.rssi2), " dBm" }) + end + return table.concat({ tostring(rssi1), " dBm" }) + end) + ) + + createDisplayRow( + fields, + "SNR", + whenConnected(function() + local rsnr = Telemetry.link.rsnr + if rsnr == nil then + return "--" + end + if not Telemetry.hasSnr() then + return "n/a" -- FLRC: the sensor is a permanent 0, not a reading + end + return table.concat({ tostring(rsnr), " dB" }) + end) + ) + + createDisplayRow( + fields, + "Active Antenna", + whenConnected(function() + if not Telemetry.hasDiversity() then + return "N/A" + end + -- 1-based on purpose; EdgeTX shows raw ANT 0/1 + if Telemetry.link.ant == 0 then + return "Ant 1" + end + if Telemetry.link.ant == 1 then + return "Ant 2" + end + return "--" + end) + ) + + 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 + ) + + 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) + ) + + 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) + + createSectionHeader(fields, "GPS") + + createDisplayRow( + fields, + "Satellites", + whenConnected(function() + local sats = Telemetry.link.sats + if sats == nil then + return "--" + end + return tostring(sats) + end) + ) + + createDisplayRow( + fields, + "Speed", + whenConnected(function() + local gspd = Telemetry.link.gspd + if gspd == nil then + return "--" + end + return string.format("%.1f", gspd) + end) + ) + + createDisplayRow( + fields, + "Altitude", + whenConnected(function() + local alt = Telemetry.link.alt + if alt == nil then + return "--" + end + return tostring(alt) + end) + ) + + -- Last known position survives link loss. + 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..aa3e948 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/hd.lua @@ -0,0 +1,96 @@ +--------------------------------------------------------------------------- +-- 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 = {} + +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints for 800x480. +WidgetUI.breakpoints = { + topBarW = 200, + quarterH = 104, + thirdH = 146, + halfH = 154, +} + +-- The full layout uses the largest LQ font that fits. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = MIDSIZE }, + half = { hero = MIDSIZE }, + full = { heroLadder = { DBLSIZE, MIDSIZE } }, +} + +-- Max bar thickness. +local BAR_H = 8 + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6 zones. +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 zones. +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 zones. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +end + +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..4284c70 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/portrait.lua @@ -0,0 +1,84 @@ +--------------------------------------------------------------------------- +-- 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 = {} + +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints for 320x480 portrait. +-- No half layout: portrait zones past 1/3 fit the full one. +WidgetUI.breakpoints = { + topBarW = 80, + quarterH = 78, + thirdH = 110, +} + +-- The full layout uses the largest LQ font that fits. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- Max bar thickness. +local BAR_H = 5 + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6 zones. +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 zones. +function WidgetUI.buildThird(w, h, opa) + local m = measured() + Components.thirdTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.third.hero, + barH = BAR_H, + }) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +end + +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..20ab796 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd.lua @@ -0,0 +1,96 @@ +--------------------------------------------------------------------------- +-- 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 = {} + +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + quarterH = 52, + thirdH = 73, + halfH = 108, +} + +-- The full layout uses the largest LQ font that fits. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- Max bar thickness. +local BAR_H = 6 + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6 zones. +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 zones. +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 zones. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +end + +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..e11243a --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/sd_tall.lua @@ -0,0 +1,96 @@ +--------------------------------------------------------------------------- +-- 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 = {} + +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints for 480x320. +WidgetUI.breakpoints = { + topBarW = 100, + quarterH = 62, + thirdH = 86, + halfH = 108, +} + +-- The full layout uses the largest LQ font that fits. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { DBLSIZE, MIDSIZE, BOLD } }, +} + +-- Max bar thickness. +local BAR_H = 6 + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6 zones. +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 zones. +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 zones. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +end + +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..a2ddf4f --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/small.lua @@ -0,0 +1,96 @@ +--------------------------------------------------------------------------- +-- 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 = {} + +local metrics +local function measured() + if not metrics then + metrics = Components.measure() + end + return metrics +end + +-- Breakpoints for 320x240. +WidgetUI.breakpoints = { + topBarW = 80, + quarterH = 42, + thirdH = 58, + halfH = 82, +} + +-- The full layout uses the largest LQ font that fits. +WidgetUI.fonts = { + compact = { hero = SMLSIZE }, + third = { hero = BOLD }, + half = { hero = BOLD }, + full = { heroLadder = { MIDSIZE, BOLD } }, +} + +-- Max bar thickness. +local BAR_H = 5 + +local TopBarUI = loadScript("/WIDGETS/ELRSTelemetry/ui/topbar.lua")({ Display = Display }) + +--- 1/4 and 1/6 zones. +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 zones. +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 zones. +function WidgetUI.buildHalf(w, h, opa) + local m = measured() + Components.halfTier(w, h, opa, m, { + heroFont = WidgetUI.fonts.half.hero, + barH = BAR_H, + }) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + local m = measured() + Components.fullTier(w, h, opa, m, { + heroLadder = WidgetUI.fonts.full.heroLadder, + barH = BAR_H, + }) +end + +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..9de6602 --- /dev/null +++ b/src/WIDGETS/ELRSTelemetry/ui/topbar.lua @@ -0,0 +1,71 @@ +--------------------------------------------------------------------------- +-- ELRS Telemetry Widget - Shared Top Bar UI -- +-- Matches the stock Value widget's layout: RSSI over LQ. -- +--------------------------------------------------------------------------- + +local ctx = ... +local Display = ctx.Display + +local TopBarUI = {} + +local VALUE_Y = math.floor(14 * lvgl.LCD_SCALE + 0.5) -- stock Value widget offset + +-- PRIMARY2: the top bar is on the dark header. +local function barColor() + if not Display.isConnected() then + return COLOR_THEME_DISABLED + end + if Display.isMismatch() then + return COLOR_THEME_WARNING + end + return COLOR_THEME_PRIMARY2 +end + +local function smallText() + if not Display.isConnected() then + return "--" + end + if Display.isMismatch() then + return "Mismatch" + end + return Display.rssiText() +end + +local function bigText() + if not Display.isConnected() or Display.isMismatch() then + return "--" + end + return table.concat({ Display.lqHeroText(), "%" }) +end + +function TopBarUI.build(w, h) + lvgl.build({ + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = STDSIZE, + color = barColor, + text = smallText, + }, + { + type = lvgl.LABEL, + x = 0, + y = VALUE_Y, + font = MIDSIZE, + color = barColor, + text = bigText, + }, + }, + }, + }) +end + +return TopBarUI diff --git a/src/WIDGETS/ELRSVTXAdmin/loadable.lua b/src/WIDGETS/ELRSVTXAdmin/loadable.lua new file mode 100644 index 0000000..244cae3 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/loadable.lua @@ -0,0 +1,531 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget - Core -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua. State is per -- +-- instance, except the shared PresetsStorage (its latch dedupes 6POS). -- +--------------------------------------------------------------------------- + +local zone, options, crsf, CRSFSession, PresetsStorage = ... + +-- ============================================================================ +-- VTXAdmin: client of the module's VTX Administrator +-- ============================================================================ + +-- Passive: also sees fields sibling instances requested +local session + +local VTXAdmin = { + PHASE_INIT = 0, + PHASE_NO_MODULE = 1, + PHASE_DISCOVER_ROOT = 2, + PHASE_DISCOVER_CHILDREN = 3, + PHASE_DISCOVER_VTX = 4, + PHASE_READY = 5, + PHASE_SENDING = 6, + + phase = 0, -- PHASE_INIT + + -- Detects suspension by a standalone tool + lastTick = 0, + + DEBOUNCE = 20, -- 200ms in getTime() ticks (10ms each) + + -- Discovered by name at runtime + ids = { + folder = nil, + band = nil, + channel = nil, + power = nil, + pitmode = nil, + send = nil, + }, + + -- Band 0 has no letter: "Off" for the VTX, "--" for an unused preset + BAND_LETTERS = { "A", "B", "E", "F", "R", "L" }, + BAND_VALUES = { Off = 0, A = 1, B = 2, E = 3, F = 4, R = 5, L = 6 }, + + statusText = "Initializing...", + + -- Mutated in place; UI closures hold references + state = { + band = 0, -- 0=Off, 1=A, 2=B, 3=E, 4=F, 5=R, 6=L + bandLetter = "?", + channel = 0, + power = 0, + pitmode = false, -- true only when pit mode is confirmed on + pitmodeAux = nil, -- switch name when pit mode is bound to an aux switch + }, + + -- Edited in full screen; mutated in place + desired = { + band = 5, -- Raceband + channel = 1, + power = 0, + pitmode = 0, + }, +} + +--- Parse "VTX Admin (BAND:CHANNEL[:POWER[:PITMODE]])" into state. +--- PITMODE is "P", or an aux label plus up/down arrow when switch-bound. +--- Band Off drops the suffix, power "-" drops power and pit mode. +--- Aux binding sets pitmodeAux only: the name never has the switch position. +local function parseFolderName(name) + local s = VTXAdmin.state + local content = string.match(name, "%((.+)%)") + if not content then + s.band = 0 + s.bandLetter = "Off" + s.channel = 0 + s.power = 0 + s.pitmode = false + s.pitmodeAux = nil + return true + end + + local parts = {} + for part in string.gmatch(content, "([^:]+)") do + parts[#parts + 1] = part + end + if #parts < 2 then + return false + end + + s.bandLetter = parts[1] + s.band = VTXAdmin.BAND_VALUES[parts[1]] or 0 + s.channel = tonumber(parts[2]) or 0 + s.power = tonumber(parts[3]) or 0 + if #parts < 4 then + s.pitmode = false + s.pitmodeAux = nil + elseif parts[4] == "P" then + s.pitmode = true + s.pitmodeAux = nil + else + -- Strip the trailing arrow (multi-byte CHAR_UP/CHAR_DOWN) + s.pitmode = false + local part = parts[4] + if CHAR_UP and string.sub(part, -#CHAR_UP) == CHAR_UP then + s.pitmodeAux = string.sub(part, 1, -#CHAR_UP - 1) + elseif CHAR_DOWN and string.sub(part, -#CHAR_DOWN) == CHAR_DOWN then + s.pitmodeAux = string.sub(part, 1, -#CHAR_DOWN - 1) + else + s.pitmodeAux = string.sub(part, 1, -2) + end + end + return true +end + +function VTXAdmin.isTuned() + return VTXAdmin.isActive() and VTXAdmin.state.band > 0 +end + +function VTXAdmin.isDisabled() + return VTXAdmin.isActive() and VTXAdmin.state.band == 0 +end + +--- Power "-" drops power and pit mode from the folder name. +function VTXAdmin.hasPower() + return VTXAdmin.isTuned() and VTXAdmin.state.power > 0 +end + +function VTXAdmin.syncDesiredFromState() + local s = VTXAdmin.state + local d = VTXAdmin.desired + d.band = s.band + d.channel = s.channel + d.power = s.power + d.pitmode = s.pitmode and 1 or 0 +end + +-- ============================================================================ +-- VTXAdmin: state machine query helpers +-- ============================================================================ + +function VTXAdmin.isReady() + return VTXAdmin.phase == VTXAdmin.PHASE_READY +end + +function VTXAdmin.isSending() + return VTXAdmin.phase == VTXAdmin.PHASE_SENDING +end + +function VTXAdmin.isActive() + return VTXAdmin.phase == VTXAdmin.PHASE_READY or VTXAdmin.phase == VTXAdmin.PHASE_SENDING +end + +--- Presets are local, so they show before discovery finishes. +function VTXAdmin.hasModule() + return VTXAdmin.phase ~= VTXAdmin.PHASE_NO_MODULE +end + +-- ============================================================================ +-- VTXAdmin: field handler (session onFieldUpdate callback) +-- ============================================================================ + +local function onField(field) + local fieldId = field.id + local fieldName = field.name + + if VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_ROOT then + if fieldId == 0 and field.type == crsf.CONST.FIELD_FOLDER then + -- Session auto-queues the root children + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_CHILDREN + VTXAdmin.statusText = "Discovering fields..." + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_CHILDREN then + if field.type == crsf.CONST.FIELD_FOLDER and string.sub(fieldName, 1, 9) == "VTX Admin" then + VTXAdmin.ids.folder = fieldId + parseFolderName(fieldName) + session:loadFolder(fieldId) + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_VTX + VTXAdmin.statusText = "Loading VTX fields..." + elseif not session:isLoading() and VTXAdmin.ids.folder == nil then + VTXAdmin.statusText = "VTX Admin not found" + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_DISCOVER_VTX then + if fieldName == "Band" or fieldName == "Band/Enable" then + VTXAdmin.ids.band = fieldId + elseif fieldName == "Channel" then + VTXAdmin.ids.channel = fieldId + elseif fieldName == "Pwr Lvl" then + VTXAdmin.ids.power = fieldId + elseif fieldName == "Pitmode" then + VTXAdmin.ids.pitmode = fieldId + elseif fieldName == "Send VTx" then + VTXAdmin.ids.send = fieldId + end + + if not session:isLoading() then + if + VTXAdmin.ids.band + and VTXAdmin.ids.channel + and VTXAdmin.ids.power + and VTXAdmin.ids.pitmode + and VTXAdmin.ids.send + then + VTXAdmin.phase = VTXAdmin.PHASE_READY + VTXAdmin.statusText = "" + VTXAdmin.syncDesiredFromState() + else + VTXAdmin.statusText = "VTX fields incomplete" + end + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_READY then + if fieldId == VTXAdmin.ids.folder then + parseFolderName(fieldName) + end + end +end + +session = CRSFSession.new({ + acceptUnsolicited = true, + responseTimeout = 50, -- always the local TX module + onFieldUpdate = onField, +}) + +-- ============================================================================ +-- VTXAdmin: 6POS quick-change and push-trigger automation +-- ============================================================================ + +local function mapTo6Pos(value) + local pos = math.floor((value + 1024) * 6 / 2049) + 1 + if pos < 1 then + pos = 1 + end + if pos > 6 then + pos = 6 + end + return pos +end + +--- Shared latch: one write per edge per radio, not per instance. +local function process6Pos() + if not PresetsStorage.enabled then + return + end + if PresetsStorage.source == 0 then + return + end + + local value = getValue(PresetsStorage.source) + if value == nil then + return + end + + local latch = PresetsStorage.latch + local pos = mapTo6Pos(value) + local now = getTime() + + -- Debounce; shared, as the source is radio state + if pos ~= latch.stablePos then + latch.stablePos = pos + latch.stableTime = now + return + end + if now - latch.stableTime < VTXAdmin.DEBOUNCE then + return + end + + -- Latch only once writable, or the edge is lost for every instance. + -- This also applies the boot position on the first ready tick. + if not VTXAdmin.isReady() then + return + end + + -- A collection change also retunes + if pos == latch.lastPos and PresetsStorage.collection == latch.lastCollection then + return + end + latch.lastPos = pos + latch.lastCollection = PresetsStorage.collection + + -- Unused slot: leave the VTX alone, no retry + local preset = PresetsStorage.items[pos] + if not preset or preset.band == 0 then + return + end + + VTXAdmin.applyPreset(preset.band, preset.channel) + if PresetsStorage.autoPushVtx then + VTXAdmin.pushToVtx() + end +end + +--- Rising edge on pushSource sends config; one push per radio. +local function processPushTrigger() + if PresetsStorage.autoPushVtx then + return + end + if PresetsStorage.pushSource == 0 then + return + end + + local val = getValue(PresetsStorage.pushSource) + if val == nil then + return + end + + local latch = PresetsStorage.latch + local high = val > 0 + + -- New source: adopt its level without firing (also seeds at boot) + if PresetsStorage.pushSource ~= latch.pushSourceSeen then + latch.pushSourceSeen = PresetsStorage.pushSource + latch.pushLastHigh = high + return + end + + -- Latch only once sendable, or the edge is lost + if not VTXAdmin.isReady() and not VTXAdmin.isSending() then + return + end + + local wasHigh = latch.pushLastHigh + latch.pushLastHigh = high + + if high and not wasHigh then + VTXAdmin.pushToVtx() + end +end + +-- ============================================================================ +-- VTXAdmin: state machine tick +-- ============================================================================ + +function VTXAdmin.tick() + local now = getTime() + + -- Resumed after >1 s: a tool may have changed config, so read back once. + -- Never poll: each read replaces an RC frame. + if VTXAdmin.lastTick > 0 and now - VTXAdmin.lastTick > 100 and VTXAdmin.phase == VTXAdmin.PHASE_READY then + session:refreshField(VTXAdmin.ids.folder, 0, 3) + end + VTXAdmin.lastTick = now + + if VTXAdmin.phase == VTXAdmin.PHASE_INIT then + if crsf.hasCrsfModule() then + VTXAdmin.phase = VTXAdmin.PHASE_DISCOVER_ROOT + VTXAdmin.statusText = "Discovering..." + session:reloadAll() + else + VTXAdmin.phase = VTXAdmin.PHASE_NO_MODULE + VTXAdmin.statusText = "No CRSF module" + end + elseif VTXAdmin.phase == VTXAdmin.PHASE_SENDING and not session:isWriting() then + print("VTXAdmin: write queue drained") + VTXAdmin.phase = VTXAdmin.PHASE_READY + -- ~100 ms for the module to apply the change + session:refreshField(VTXAdmin.ids.folder, 10, 3) + end + + session:tick() + + -- After the pump, so queued writes go out next tick + process6Pos() + processPushTrigger() +end + +-- ============================================================================ +-- VTXAdmin: write queue builder +-- ============================================================================ + +--- Write changed fields; pushToVtx() sends them to the VTX. +function VTXAdmin.writeConfig() + if not VTXAdmin.isReady() then + print("VTXAdmin: writeConfig() skipped - not ready") + return + end + + local s = VTXAdmin.state + local d = VTXAdmin.desired + + print(table.concat({ + "VTXAdmin: writeConfig() desired: band=", + d.band, + " ch=", + d.channel, + " pwr=", + d.power, + " pit=", + tostring(d.pitmode), + })) + print(table.concat({ + "VTXAdmin: writeConfig() current: band=", + s.band, + " ch=", + s.channel, + " pwr=", + s.power, + " pit=", + tostring(s.pitmode), + })) + + local wrote = 0 + if d.band ~= s.band then + session:writeField({ id = VTXAdmin.ids.band, value = d.band }) + wrote = wrote + 1 + end + if d.channel ~= s.channel then + session:writeField({ id = VTXAdmin.ids.channel, value = d.channel }) + wrote = wrote + 1 + end + if d.power ~= s.power then + session:writeField({ id = VTXAdmin.ids.power, value = d.power }) + wrote = wrote + 1 + end + + local desiredPit = d.pitmode + local currentPit = s.pitmode and 1 or 0 + if desiredPit ~= currentPit then + session:writeField({ id = VTXAdmin.ids.pitmode, value = desiredPit }) + wrote = wrote + 1 + end + + print(table.concat({ "VTXAdmin: writeConfig() wrote ", wrote, " field(s)" })) + + if wrote > 0 then + VTXAdmin.phase = VTXAdmin.PHASE_SENDING + end +end + +--- Re-sync first: desired may hold stale power/pitmode. +function VTXAdmin.applyPreset(band, channel) + VTXAdmin.syncDesiredFromState() + VTXAdmin.desired.band = band + VTXAdmin.desired.channel = channel + VTXAdmin.writeConfig() +end + +function VTXAdmin.pushToVtx() + if not VTXAdmin.isReady() and VTXAdmin.phase ~= VTXAdmin.PHASE_SENDING then + print("VTXAdmin: pushToVtx() skipped - not ready") + return + end + + print("VTXAdmin: pushToVtx() - sending Send VTx command") + session:writeField({ id = VTXAdmin.ids.send, value = crsf.CONST.CMD_CLICK }) + VTXAdmin.phase = VTXAdmin.PHASE_SENDING +end + +-- ============================================================================ +-- Display components +-- ============================================================================ + +local VTXDisplay, WidgetLayout = loadScript("/WIDGETS/ELRSVTXAdmin/ui/display.lua")(VTXAdmin, PresetsStorage) + +-- ============================================================================ +-- Screen detection and UI loading +-- ============================================================================ + +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 + +--- Transparency option 0-5 -> 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 + +--- Loaded on first use: only one widget is full screen at a time. +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 + +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..5455c4c --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/main.lua @@ -0,0 +1,58 @@ +--------------------------------------------------------------------------- +-- VTX Administrator Widget -- +-- Requires /SCRIPTS/ELRS on the SD card. -- +--------------------------------------------------------------------------- + +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..80f221e --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/presets_storage.lua @@ -0,0 +1,145 @@ +--------------------------------------------------------------------------- +-- 6POS Preset Storage -- +-- Loaded via loadScript() from ELRSVTXAdmin/main.lua with (FileStorage) -- +-- and shared by every widget instance. -- +--------------------------------------------------------------------------- + +local FileStorage = ... + +local PATH = "/WIDGETS/ELRSVTXAdmin/presets.txt" + +-- One slot per 6POS position +local COLLECTION_COUNT = 6 +local SLOT_COUNT = 6 + +-- Worst case ~225 bytes. FileStorage reads 512; past that the tail +-- collections are lost and reset to defaults on the next save. +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 = { + COLLECTION_COUNT = COLLECTION_COUNT, + + -- collections[c][i] = { band, channel } + collections = {}, + + -- Alias of collections[collection]; read it each time, never cache it + items = {}, + + collection = 1, + + -- Not persisted; shared so each edge is consumed once per radio + latch = { + lastPos = -1, -- last consumed 6POS position + lastCollection = -1, + stablePos = -1, -- debounce candidate + stableTime = 0, + ---@type boolean? + pushLastHigh = nil, + pushSourceSeen = 0, -- source pushLastHigh came 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) +} + +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 + +--- Invalid segments stay nil and get defaults. +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 + +--- presets.txt: enabled/autoPushVtx "1"/"0", source/pushSource are source +--- IDs, collection 1..6, c1..c6 = "band,channel;...". Default slots 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 "") + 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 + +--- Swap items in one assignment: LVGL callbacks may read it any frame. +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 + +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..860176e --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/display.lua @@ -0,0 +1,340 @@ +--------------------------------------------------------------------------- +-- Minimized Display Layer -- +-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- +-- (VTXAdmin, PresetsStorage); returns VTXDisplay, WidgetLayout. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage = ... + +-- ============================================================================ +-- WidgetLayout: minimized zone container builders +-- ============================================================================ + +local WidgetLayout = {} + +--- pad overrides borderPad; a table trims vertical padding only. +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 = {} + +function VTXDisplay.showChannel() + return VTXAdmin.isTuned() +end + +function VTXDisplay.showStatus() + return not VTXAdmin.isTuned() +end + +function VTXDisplay.bandChannel() + if not VTXAdmin.isTuned() then + return "" + end + return table.concat({ VTXAdmin.state.bandLetter, VTXAdmin.state.channel }) +end + +function VTXDisplay.statusText() + if not VTXAdmin.hasModule() then + return "No module" + end + if not VTXAdmin.isActive() then + return "Loading..." + end + if VTXAdmin.state.band == 0 then + return "VTX Off" + end + return "" +end + +function VTXDisplay.statusColor() + if not VTXAdmin.hasModule() then + return RED + end + return COLOR_THEME_SECONDARY1 +end + +--- Accent colour; red when pit mode is confirmed on. +function VTXDisplay.heroColor() + if VTXAdmin.state.pitmode then + return RED + end + return COLOR_THEME_FOCUS +end + +--- "" without power: ExpressLRS hides pit mode then. +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 + +function VTXDisplay.pitHeaderColor() + return VTXAdmin.state.pitmode and RED or COLOR_THEME_SECONDARY1 +end + +--- e.g. "P2"; "" without power. +function VTXDisplay.powerShort() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "P", VTXAdmin.state.power }) +end + +--- e.g. "Power 2"; "" without power. +function VTXDisplay.powerLong() + if not VTXAdmin.hasPower() then + return "" + end + return table.concat({ "Power ", VTXAdmin.state.power }) +end + +--- Name left, pit state right. A plain box: a right-aligned label needs a width. +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 + +--- Status or band/channel with power on its baseline; two flex children, +--- only the visible one takes a row. +function VTXDisplay.buildHero(font) + local heroH = select(2, lcd.sizeText("0", font)) + -- 4/5: line boxes include descender room + local drop = math.max(0, math.floor((heroH - select(2, lcd.sizeText("0", SMLSIZE))) * 4 / 5)) + return { + type = lvgl.BOX, + h = heroH, + visible = VTXDisplay.showStatus, + children = { + { + type = lvgl.LABEL, + x = 0, + y = math.max(0, math.floor((heroH - select(2, lcd.sizeText("0", BOLD))) / 2)), + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + }, + }, + }, { + 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 + +--- Six fixed cells, the latched one lit; unset presets show "--". +--- nil when presets are off or there is no module. +function VTXDisplay.buildCells(spec) + if not (VTXAdmin.hasModule() and PresetsStorage.enabled) then + return nil + end + local gap = 4 + local font = spec.font or SMLSIZE + local cellW = math.max((lcd.sizeText("R8", font)), (lcd.sizeText("--", font))) + 2 * lvgl.PAD_SMALL + -- Squeeze, then drop to SMLSIZE, rather than clip + 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, + -- Background-only opacity; 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, + flexPad = gap, + align = LEFT, + visible = VTXAdmin.hasModule, + children = cells, + } +end + +--- Name then band/channel on one line; extras are appended labels. +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, + -- PAD_TINY runs "VTX Admin" and "R1" together + 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..a5e263a --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/fullscreen.lua @@ -0,0 +1,322 @@ +--------------------------------------------------------------------------- +-- Full-Screen Editor -- +-- Loaded via loadScript() from ELRSVTXAdmin/loadable.lua with -- +-- (VTXAdmin, PresetsStorage); returns the FullScreenUI table. -- +-- Built once per entry and never rebuilt, so focus and scroll survive. -- +-- Values refresh through get(); numberEdit re-polls only on 2.12.1+, -- +-- older firmware catches up on the next entry. -- +--------------------------------------------------------------------------- + +local VTXAdmin, PresetsStorage = ... + +local FullScreenUI = {} + +-- ============================================================================ +-- Row helpers +-- ============================================================================ + +local LABEL_PCT = (LCD_W < LCD_H) and 42 or 50 + +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 layout +-- ============================================================================ + +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, + }) + + if not VTXAdmin.hasModule() then + local Dialogs = loadScript("/SCRIPTS/ELRS/ui/lvgl/dialogs.lua")() + local heading = { type = lvgl.LABEL, text = "No module found. Check Model Setup:", color = COLOR_THEME_PRIMARY1 } + local rows = Dialogs.noModuleChecklist(COLOR_THEME_DISABLED) + table.insert(rows, 1, heading) + pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + children = rows, + }) + return + end + + local fields = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_COLUMN, + }) + + 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) + + -- ExpressLRS hides pit mode 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, + }) + + 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." + ) + + 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..deb9af8 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/hd.lua @@ -0,0 +1,180 @@ +--------------------------------------------------------------------------- +-- 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 for 800x480. +WidgetUI.breakpoints = { + topBarW = 200, + sixthH = 78, -- between 1/6 (~58-69) and 1/4 (~87-104) + -- 1/6 zones stack the cells on a second row from here up + 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) + -- Full-width zone; gates cells on the 1/6 inline row + wideW = 560, +} + +-- cellH is the preset cell height; cells is its font. +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 }, +} + +local CELL_ROUNDED = 6 + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- nil when presets are off. +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 + +--- Header, band/channel with power, 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 zones. Two rows fit only by trimming vertical padding; otherwise one +--- inline row. +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, + -- Cells flush against the edge look clipped + 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, + -- Fixed width so the row doesn't shift + 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 + 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 zones. +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, + -- Cells flush against the edge look clipped + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3 zones. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2 zones. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +end + +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..9850cb2 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/portrait.lua @@ -0,0 +1,160 @@ +--------------------------------------------------------------------------- +-- 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 for 320x480. 1/6 zones are one row; no stacking. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 70, + quarterH = 100, + thirdH = 140, + halfH = 210, + -- Full-width zone; gates cells on the 1/6 inline row + wideW = 240, +} + +-- cellH is the preset cell height; cells is its font. +WidgetUI.fonts = { + -- 1/6 inline row sizes cells to the zone + 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 }, +} + +local CELL_ROUNDED = 3 + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- nil when presets are off. +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 + +--- Header, band/channel with power, 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 zones: one inline row. +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, + -- Fixed width so the row doesn't shift + 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 + 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 zones. +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, + -- Cells flush against the edge look clipped + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3 zones. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2 zones. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +end + +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..c26bd0b --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd.lua @@ -0,0 +1,180 @@ +--------------------------------------------------------------------------- +-- 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 for 480x272. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + -- 1/6 zones stack the cells on a second row from here up + sixthStackH = 40, + sixthStackW = 180, + quarterH = 70, + thirdH = 100, + halfH = 125, + -- Full-width zone; gates cells on the 1/6 inline row + wideW = 340, +} + +-- cellH is the preset cell height; cells is its font. +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 }, +} + +local CELL_ROUNDED = 4 + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- nil when presets are off. +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 + +--- Header, band/channel with power, 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 zones. Two rows fit only by trimming vertical padding; otherwise one +--- inline row. +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, + -- Cells flush against the edge look clipped + 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, + -- Fixed width so the row doesn't shift + 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 + 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 zones. +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, + -- Cells flush against the edge look clipped + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3 zones. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2 zones. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +end + +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..1c6987d --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/sd_tall.lua @@ -0,0 +1,181 @@ +--------------------------------------------------------------------------- +-- 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 for 480x320. +WidgetUI.breakpoints = { + topBarW = 100, + sixthH = 50, + -- 1/6 zones stack the cells on a second row from here up + sixthStackH = 40, + sixthStackW = 180, + quarterH = 62, + thirdH = 118, + halfH = 147, + -- Full-width zone; gates cells on the 1/6 inline row + wideW = 340, +} + +-- cellH is the preset cell height; cells is its font. +-- 1/3 zones start at 62px: too short for MIDSIZE over the cells. +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 }, +} + +local CELL_ROUNDED = 4 + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- nil when presets are off. +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 + +--- Header, band/channel with power, 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 zones. Two rows fit only by trimming vertical padding; otherwise one +--- inline row. +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, + -- Cells flush against the edge look clipped + 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, + -- Fixed width so the row doesn't shift + 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 + 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 zones. +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, + -- Cells flush against the edge look clipped + bottom = lvgl.PAD_SMALL + lvgl.PAD_TINY, + }) +end + +--- 1/3 zones. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2 zones. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +end + +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..5184e85 --- /dev/null +++ b/src/WIDGETS/ELRSVTXAdmin/ui/small.lua @@ -0,0 +1,154 @@ +--------------------------------------------------------------------------- +-- 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 for 320x240. 1/6 zones are too short to stack. +WidgetUI.breakpoints = { + topBarW = 80, + sixthH = 38, + quarterH = 54, + thirdH = 76, + halfH = 100, + -- Full-width zone; gates cells on the 1/6 inline row + wideW = 240, +} + +-- cellH is the preset cell height; cells is its font. +WidgetUI.fonts = { + -- 1/6 inline row sizes cells to the zone + 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 }, +} + +local CELL_ROUNDED = 3 + +local TopBarUI = loadScript("/WIDGETS/ELRSVTXAdmin/ui/topbar.lua")({ + VTXDisplay = VTXDisplay, +}) + +local function statusLabel() + return { + type = lvgl.LABEL, + align = LEFT, + font = BOLD, + color = VTXDisplay.statusColor, + text = VTXDisplay.statusText, + visible = VTXDisplay.showStatus, + } +end + +--- nil when presets are off. +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 + +--- Header, band/channel with power, 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 zones: one inline row. +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, + -- Fixed width so the row doesn't shift + 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 + 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 zones. +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 zones. +function WidgetUI.buildThird(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.third, w)) +end + +--- 1/2 zones. +function WidgetUI.buildHalf(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.half, w)) +end + +--- Full zone. +function WidgetUI.buildFull(w, h, opa) + WidgetLayout.column(w, h, opa, cardRows(WidgetUI.fonts.full, w)) +end + +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..dabd714 --- /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 = {} + +local VALUE_Y = math.floor(14 * lvgl.LCD_SCALE + 0.5) -- stock Value widget offset + +function TopBarUI.build(w, h) + lvgl.build({ + { + type = lvgl.BOX, + x = 0, + y = 0, + w = w, + h = h, + children = { + { + type = lvgl.LABEL, + x = 0, + y = 0, + font = STDSIZE, + color = COLOR_THEME_PRIMARY2, + text = "VTX", + }, + { + type = lvgl.LABEL, + x = 0, + y = VALUE_Y, + font = MIDSIZE, + color = COLOR_THEME_PRIMARY2, + text = function() + if VTXDisplay.showStatus() then + return "--" + end + return VTXDisplay.bandChannel() + end, + }, + }, + }, + }) +end + +return TopBarUI