From 8ec07451aea93e74e44bb64aea92ddde7592fc7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Ko=C5=A1tial?= Date: Sat, 15 Aug 2026 21:11:03 +0200 Subject: [PATCH] Add Scooter Dashboard Support Connects a stock Xiaomi or Ninebot scooter dashboard to a VESC-based controller: speed modes, secret modes, lock with alarm, remappable button and lever gestures, cruise control, rear light and BMS support, configured from the package UI and stored on the ESC. --- Makefile | 1 + res_all.qrc | 1 + scooter_dashboard_support/LICENSE | 674 +++ scooter_dashboard_support/Makefile | 13 + scooter_dashboard_support/README.md | 343 ++ scooter_dashboard_support/pkgdesc.qml | 15 + .../scooter_support.lisp | 2097 +++++++++ scooter_dashboard_support/ui.qml | 3921 +++++++++++++++++ scooter_dashboard_support/version | 1 + 9 files changed, 7066 insertions(+) create mode 100644 scooter_dashboard_support/LICENSE create mode 100644 scooter_dashboard_support/Makefile create mode 100644 scooter_dashboard_support/README.md create mode 100644 scooter_dashboard_support/pkgdesc.qml create mode 100644 scooter_dashboard_support/scooter_support.lisp create mode 100644 scooter_dashboard_support/ui.qml create mode 100644 scooter_dashboard_support/version diff --git a/Makefile b/Makefile index 8e48f68a3..ccd246e6b 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,7 @@ PKGS += vdisp lib_tca9535 vbms_harmony32 vbms_harmony16 PKGS += dash35b vl_bike_39p lib_bq27441 boosted_doctor dash16 PKGS += lib_tca9534 UnleashedCreativityLights wheelie_limiter PKGS += mt6701_config dash_esc vesc_scooter_support lib_esp_led_strip vl_link_status +PKGS += scooter_dashboard_support TEST_PKGS = blacktip_dpv diff --git a/res_all.qrc b/res_all.qrc index 2538e3d20..591543e45 100644 --- a/res_all.qrc +++ b/res_all.qrc @@ -36,6 +36,7 @@ vesc_scooter_support/vesc_scooter_support.vescpkg lib_esp_led_strip/esp_led_strip.vescpkg vl_link_status/vl_link_status.vescpkg + scooter_dashboard_support/scooter_dashboard_support.vescpkg diff --git a/scooter_dashboard_support/LICENSE b/scooter_dashboard_support/LICENSE new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/scooter_dashboard_support/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/scooter_dashboard_support/Makefile b/scooter_dashboard_support/Makefile new file mode 100644 index 000000000..27cf9a287 --- /dev/null +++ b/scooter_dashboard_support/Makefile @@ -0,0 +1,13 @@ +VESC_TOOL ?= vesc_tool + +PKG = scooter_dashboard_support.vescpkg + +all: $(PKG) + +$(PKG): pkgdesc.qml ui.qml scooter_support.lisp README.md version + $(VESC_TOOL) --buildPkgFromDesc pkgdesc.qml --testPkgDesc 'vesc:maxim 120' --testPkgDesc 'vesc:pronto' + +clean: + rm -f $(PKG) + +.PHONY: all clean diff --git a/scooter_dashboard_support/README.md b/scooter_dashboard_support/README.md new file mode 100644 index 000000000..24cdfe438 --- /dev/null +++ b/scooter_dashboard_support/README.md @@ -0,0 +1,343 @@ +# Scooter Dashboard Support + +[Scooter Dashboard Support on GitHub](https://github.com/lucidnx/Scooter%5FDashboard%5FSupport) + +For VESC-based controllers. Connect a Xiaomi or Ninebot dashboard to a VESC +controller. Speed modes, secret modes, lock with alarm, fully remappable +button/lever gestures, cruise control, rear light, +BMS support and a live remote-control dashboard - all configured from a phone-friendly UI +and stored on the ESC. + +## ⚠️ Disclaimer + +**Use at your own risk.** This is community software provided **as-is, without any warranty** +of any kind, express or implied. It controls a moving vehicle - a bug, misconfiguration, or +hardware fault could cause **loss of motor power, unintended acceleration or braking, or +failure of the lock, alarm, or cruise features**, potentially resulting in a crash, injury, +or damage. + +By installing or using this package you accept **full responsibility** for it. The authors +and contributors accept **no liability** for any damage, injury, or loss arising from its use. + +- **Test everything on a stand, wheels off the ground, before riding** - especially throttle, + brake, mode limits, lock/unlock, and cruise control. +- **Cruise control is experimental** - verify the throttle/brake cancel works reliably before + trusting it, and try it first at low speed on open ground. +- Set your motor current, battery, and temperature limits correctly in VESC Tool; this package + does not protect your hardware from bad configuration. +- Riding a modified scooter may be illegal on public roads in your jurisdiction and may void + warranties. Wear appropriate safety gear. + +If you don't accept these terms, don't install it. + +## 🛠️ Functions + +One package for everything - the model is stored on the ESC and selected in the UI: + +- **G30** - Ninebot G30 dashboard +- **M365/1S/PRO2** - Xiaomi M365, 1S, Essential and PRO 2 dashboards +- **G2** - Ninebot Max G2 dashboard. **Untested** - no G2 hardware has run it yet +- **Slave** - secondary ESC in a dual setup, only runs the CAN code server + +### 🛞 Speed modes +- Three modes (Eco / Drive / Sport) plus three **secret** modes, each with its own speed, + current, watts, field weakening and overmodulation +- **Current %** is a percentage of your Motor Current Max, capped at 100%; **Overmodulation** + is floored at 1.0, so neither can overdrive the motor +- Each parameter has its own apply toggle - unchecked parameters never touch your motor + config, so you can keep your own field weakening setup +- Selectable startup mode + +### 🎛️ Gestures +Lock, mode switching, headlight, secret mode and leaving secret mode are fully remappable: + +- **Lever combination** - any mix of Brake / Throttle to hold, or none +- **Button presses** - 1-5, or **No** to fire from the levers alone after half a second +- **Locked** - restrict a gesture to the locked state only +- **Secret** turns the three secret modes on and off. While secret is on, the Modes gesture + cycles the secret Eco / Drive / Sport set instead of the normal one +- **Secret OFF** is a second, separate gesture that can only ever *leave* secret mode - it + does nothing when secret is already off, so it is a way out that cannot turn it on by + mistake +- **Speed limit** - gestures stop working above **Disable Gestures above** in Setup. The + default is 0.1 km/h, which means standstill only; raise it and you can change modes, the + headlight or secret mode while riding. **Lock** and **power off** are the exceptions - they + are never accepted with the wheel turning, whatever that is set to +- A gesture whose combination does not include a lever still needs that lever released to + match, so let go of the throttle for those +- Turning the scooter on always works, regardless of mapping + +### 🔒 Lock & alarm +- Motor braked when pushed, alarm with beeping and optional siren on gyro or wheel movement +- Configurable thresholds and volume, and an optional "Disable Secret when Locked" + +### 🚀 Cruise control (experimental) +- Hold a steady speed with the throttle for the configured delay and the scooter keeps it +- **Cancels on any throttle or brake press** and your live lever takes over the same instant. + It does not cancel on speed alone, so a bumpy road or traction control cannot drop it +- Arms only inside a configurable **min/max speed window** (default 5 - 100 km/h) +- The Setup switch is a **master switch**: with it off, cruise cannot be turned on from the + Control tab, an app or a gesture + +### 📱 Control tab +- Live speed dial with a power sub-dial that scales to the active mode's watt limit times + the number of controllers on the bus, and a battery bar carrying charge in the middle + with estimated range at whichever end is one solid colour +- **Four readouts** under the bar. Hold one to pick what it shows: voltage, battery or + motor current, controller, motor or battery temperature, battery %, uptime or trip + distance. Temperatures flash red above their warning thresholds +- A disabled function reads as off: with cruise or secret switched off in Setup, its button + greys out and stops responding rather than sitting there coloured +- Buttons for power, lock/unlock, headlight, secret, cruise and mode selection + +### 🔗 Third-party app support +**NineDash**, **m365 Tools** and the **official Segway Ninebot app** connect over the +dashboard's own BLE module, so no extra hardware or wiring is needed. Tested on a G30, +including pairing. + +- **Live data** - speed, battery %, voltage, current, power, temperature, odometer, trip + distance and time, average speed, range, error and alarm codes +- **Controls** - lock/unlock, headlight, rear light, cruise, speed mode, secret, buzzer and + "find my scooter". Apps have no headlight, secret or speed-mode control of their own, so + three of theirs are borrowed: **KERS** selects the speed mode, **Walk mode** toggles + secret, **Direct power control** toggles the headlight +- **Battery screen** is populated on Xiaomi - the BMS is emulated +- **Shutdown from the app** switches the dashboard off, refused above walking pace +- **App pairing PIN** - set your own 6-digit code in Setup. The package answers it, but + none of the three apps above ever asks: they treat a headlight state change as the + pairing confirmation instead, so the code is there for an app that wants it +- Can be turned off in **Setup -> Miscellaneous** for the sharpest possible throttle response + +### 💡 Lights +- **Auto headlight** at power on +- **Rear / brake light** on the servo pin: dim tail light following the headlight (or always + on), full or blinking brake light while braking. **Tail Light Brightness** sets how dim, + in ten steps from 10 to 100% +- **Headlight and tail light off when locked**, each on its own switch, restored on unlock + +### 🖥️ Dashboard +- **Idle display** - while standing still the dash speed readout shows battery %, pack + voltage, controller or motor temperature instead. Set separately for normal and secret modes +- **BMS battery %** - a reporting VESC BMS supplies the percentage, and its pack + temperature raises the dash warning outside a hot and a cold limit you set +- **Use Imperial Units** - dash speed, every speed setting and every temperature switch + between km/h with °C and mph with °F +- **Temperature warning icon** with configurable thresholds +- **Faults reach the dashboard** - the controller's own fault code goes into the dash's + error field, so the display flashes it. The number is a **VESC** fault code shown by a + dashboard that reads it against the scooter's own table, so read it as a VESC one +- **Ninebot Max G2** - the handlebar **horn** sounds the dash buzzer, and **holding the turn + signal button** for three seconds toggles cruise control +- A long button press turns the dashboard off + +### 💾 Backup +- **Export** in Setup puts every saved setting on the clipboard as a short block of + text - paste it into a note, a file or a message to keep it +- **Import** takes that text back, fills in every field, and waits for you to press + Save. The model is not part of a backup, since it belongs to the unit +- VESC Tool gives a package no way to write a file on the phone or the desktop, so a + backup travels as text rather than as a download +- ⚠️ **Installing a new version returns every setting to its default**, so export + before you update and import afterwards. The model is kept, and the light + compensation calibration has to be run again + +### 🎚️ Throttle & brake +- **Software ADC** per channel - take throttle from the dashboard and leave brake on a lever + wired to the ADC pin, or the other way round +- **Light compensation** - the headlight sags throttle/brake voltage non-linearly, so this + applies an affine correction (offset + gain) rather than a flat offset. A guided wizard in + Setup measures it. The motor stays disengaged throughout, so **no stand is needed** +- **Motor start speed** (kick-start) + +### 🔌 Dashboard power control +- **ADC1** or **ADC2** switches the dashboard's supply through a MOSFET: **3.3 V on the + chosen pin while the scooter is on, 0 V when it is off** +- Because the pin stays low until the script runs, the dashboard no longer shows error 10 + while the VESC boots +- Off by default. The chosen pin is detached from the ADC app, so it stops working as a lever + input - on ADC2 that costs the brake, on ADC1 the throttle, unless that lever comes from + the dashboard. The UI warns in red when it does + +### 🛡️ Robustness +- **Dash link watchdog** - if the dashboard goes quiet mid-ride the package stops driving the + ADC override and drops cruise, so the controller's own command timeout releases throttle + and brake. The window follows the measured frame rate, so connecting an app cannot trip it +- **CAN slaves are told to stop** with the master, rather than holding their last command + until it times out +- **App traffic never delays the levers** - replies are composed in advance and carried + inside the dash reply the controller was going to send anyway +- Hardened UART frame parsing and supervised reader threads +- Runs from flash; settings stored on the ESC with versioned automatic migrations + +## 📋 Requirements + +- **VESC firmware 7.00**, from https://vesc-project.com/ +- **A VESC controller.** Not every unit works - if yours misbehaves, that is worth reporting +- **A supported dashboard** - Xiaomi M365 / 1S / Essential / PRO 2, or Ninebot G30 or Max G2 + +## 🔧 Setup + +### 📦 Install + +1. Connect to your VESC in VESC Tool and install this package + (**VESC Packages -> Load Custom -> select the .vescpkg**, or from the package store once listed). +2. **Install the package on every VESC unit.** On dual-motor setups, switch to the second + unit via **CAN forwarding** and install it there too - each unit runs its own copy. +3. Open the package UI (**Navigation Bar -> App UI**), go to the **Setup** tab and select + the model **for each unit**: + - the unit wired to the dashboard gets its dashboard model (**G30**, **M365/1S/PRO2** + or **G2**), + - every other unit gets **Slave**. +4. Press **Save** - the script restarts with the chosen model. The model is stored per unit. +5. Configure everything else in the **General**, **Modes** and **Setup** tabs and press **Save**. + +**Updating:** just install the new package over the old one - your settings are kept and +migrated automatically. To go back to defaults, use the **Reset** button in the UI - that +takes the model back to **Slave** as well, so pick it again afterwards. + +### ⚙️ Required VESC configuration + +The package feeds the throttle and brake from the dashboard into the VESC's ADC app, so a +few controller settings must be set (in VESC Tool, not the package UI): + +**On the dashboard unit (master):** + +- **App Settings -> General -> App to Use = `ADC`** +- **App Settings -> ADC -> General -> Control Type = `Current No Reverse Brake ADC2`** +- **App Settings -> ADC -> General -> Multiple VESCs Over CAN = `True`** (dual-motor setups) +- **App Settings -> ADC -> General -> Negative Ramping Time = `0.00 s`** - the dashboard + already sends the lever position ten to forty times a second, so a second ramp on top of + it only delays what the rider asked for +- **App Settings -> ADC -> General -> Update Rate = `1000 Hz`** - the package overrides the + ADC input as each dash frame arrives, and a slow update rate makes the app read a stale + override between frames +- Keep **Software ADC** enabled in the package **Setup** tab (default) - the dashboard + supplies throttle/brake over UART; the package overrides the ADC app inputs. Throttle + and brake are switchable separately, so you can take one from the dashboard and leave + the other on a lever wired to the ADC pin. +- For accurate battery % and range, set your pack under + **Motor Settings -> Additional Info -> Battery**: type, cell count and Ah. + +**On every other unit (slave):** + +- **App Settings -> General -> App to Use = `No App`** - a running ADC app on the slave + fights the master's commands and causes stuttering. + +**Lever detection (throttle/brake "pressed"):** + +- **App Settings -> ADC -> Mapping -> `ADC1 Start voltage` / `ADC2 Start voltage`** define + where the levers start responding. The package reuses exactly these values to decide when + a lever counts as pressed - for gestures, the brake light and cruise cancel - so there is + no separate deadband to configure. Map your levers properly in VESC Tool and everything + else follows. + +**For cruise control (optional):** + +- **App Settings -> ADC -> Buttons -> enable `Cruise Control`** (leave it *not* inverted). + The package uses the VESC's built-in cruise button, so this must be on. + +**For the rear / brake light (optional):** + +- **App Settings -> General -> enable `Servo Output`**. The light is driven as PWM on the + SERVO/PPM pin, and that pin stays dead until this is enabled - the package can look + correctly configured and still produce no light without it. +- Make sure **App to Use** is not `PPM` (or `PPM and UART`), which would claim the same pin + as an input. With `ADC` (as above) the pin is free to drive the light. + +After changing controller settings, write the configuration to each unit and power-cycle. + +### 🔌 Wiring + +| Qty | Part | +|---|---| +| 1 | Capacitor 220 µF, 25 V, low ESR, 105 °C, electrolytic, THT, ±20% | +| 1 | Capacitor 1 µF, 50 V, X7R, ceramic, THT, ±10% | +| 1 | Resistor 1 kΩ, 0.25 W, THT | +| 1 | Clip-on ferrite, 5 mm inner diameter - *optional* | + +ℹ️ **Both capacitors are optional** - the connection works without them. They are +there to stop **phantom button presses**, where the scooter reacts to presses +nobody made. The **220 µF** across **5V and GND** is a reservoir for current +spikes, and the **1 µF** across the **green button line and GND** keeps noise off +it. If you get phantom presses, fit them; if you never do, you can leave them out. + +Nothing goes on the yellow UART line. Fit the capacitors as close to the dashboard +as the wiring allows. The electrolytic is polarised: its **marked leg is the minus +and goes to GND**, getting that backwards will destroy it. The ferrite clips over +the whole bundle anywhere along its length. + +#### Rear / brake light (optional) + +Driven from the **servo/PPM pin** through an N-channel MOSFET (PWM at 200 Hz - dim tail +light, full brightness brake light). +Wiring by [Zodiak1993](https://github.com/Zodiak1993/vesc%5Fm365%5Fdash). + +Two things must both be set or the light stays dark: + +1. **VESC Tool -> App Settings -> General -> `Servo Output` = enabled** +2. **Setup tab -> `Tail Light Output (Servo/PPM)`** - the master switch. With it off the servo/PPM + pin is never touched, so it stays free for something else + +> ⚠️ **Check what your VESC's 5V output can supply before wiring it this way.** +> The dashboard runs from that rail, and a rear/brake light or headlight draws +> from the same one. On some VESCs it is not enough for all of it. If yours is +> marginal, power the lights (and/or the dashboard) from a separate step-down +> converter off the main battery instead, sharing a common ground with the VESC. + +**Which leg is which.** Hold the MOSFET with the printed face towards you and the +legs pointing down: + +| Leg | | Connects to | +|---|---|---| +| 1 - left | Gate | servo output, plus the 10 kΩ down to GND | +| 2 - middle | Drain | the tail light's negative wire | +| 3 - right | Source | GND | + +The metal tab is internally connected to leg 2 (Drain), so treat it as live and +don't let it touch anything. + +#### Dashboard power control (optional) + +Switches the step-down converter that feeds the dashboard, with a P-channel MOSFET +on the battery side driven from ADC1 or ADC2 - 3.3 V on that pin while the scooter +is on, 0 V when it is off. The optocoupler keeps the pack away from the VESC's +3.3 V pin, and the two sides share nothing at all - the step-down's ground goes +straight to the battery, both optocoupler pins sit on VESC GND. + +Set **Setup tab -> `Dashboard Power Pin`** to whichever pin you wired, and leave +that channel's software ADC off. + +| Part | | | +|---|---|---| +| Q1 | IRF9540N | P-channel, source to the battery, drain to the step-down | +| U1 | PC357 | optocoupler, isolates the 3.3 V pin from the pack | +| D2 | BZX79-C10 | clamps the gate to 10 V below the source | +| R1 | 10 kΩ | holds the gate at the source, so the default is off | +| R2 | 27 kΩ | gate pull-down through the optocoupler | +| R3 | 220 Ω | LED current from the 3.3 V pin | + +> ⚠️ **These values are for a 15S pack.** R1 and R2 divide the pack voltage to set +> how hard the MOSFET is driven, so a different cell count needs different +> resistors - too low and the MOSFET only partly turns on and overheats. +> +> **Ceiling is 80 V, and the optocoupler sets it.** While the MOSFET is off its +> collector sits at the full pack voltage, which is the PC357's limit - so **above +> 18S the optocoupler has to change too**, not just the resistors. The IRF9540N +> itself is good to 100 V. + +The MOSFET's tab is the Drain and sits at battery voltage - treat it as live. + +## 🙏 Thanks + +- **Izuna, AKA13 and Netzpfuscher** - the original VESC dashboard scripts this package + builds on +- **[Zodiak1993](https://github.com/Zodiak1993/vesc%5Fm365%5Fdash)** - rear/brake light wiring + and the BMS, overmodulation and cruise-control ideas +- **[Benjamin Vedder](https://github.com/vedderb)** - VESC, VESC Tool, LispBM and the + CAN code-server library +- **[Koxx3](https://github.com/Koxx3/SmartESC%5FSTM32%5Fv2)** - reference work for Xiaomi ESCs +- The **scooterhacking.org** community for guides and testing + +## 🔗 See Also + +`https://github.com/Koxx3/SmartESC_STM32_v2` (VESC firmware for Xiaomi ESCs) diff --git a/scooter_dashboard_support/pkgdesc.qml b/scooter_dashboard_support/pkgdesc.qml new file mode 100644 index 000000000..3e5ed067b --- /dev/null +++ b/scooter_dashboard_support/pkgdesc.qml @@ -0,0 +1,15 @@ +import QtQuick 2.15 + +Item { + property string pkgName: "Scooter Dashboard Support" + property string pkgDescriptionMd: "README.md" + property string pkgLisp: "scooter_support.lisp" + property string pkgQml: "ui.qml" + property bool pkgQmlIsFullscreen: false + property string pkgOutput: "scooter_dashboard_support.vescpkg" + + function isCompatible (fwRxParams) { + var hwType = fwRxParams.hwTypeStr().toLowerCase() + return hwType === "vesc" + } +} diff --git a/scooter_dashboard_support/scooter_support.lisp b/scooter_dashboard_support/scooter_support.lisp new file mode 100644 index 000000000..09dac17ba --- /dev/null +++ b/scooter_dashboard_support/scooter_support.lisp @@ -0,0 +1,2097 @@ +(def software-adc true) +(def software-adc2 true) +(def adc-touch 0.02) +(def temp-warning-motor 80) +(def temp-warning-fet 80) +(def bms-temp-hi 50) +(def bms-temp-lo 0) +(def show-batt-in-idle false) +(def idle-display 0) +(def last-alarm-code 0) +(def show-batt-idle-secret false) +(def min-speed 1) +(def button-safety-speed (/ 0.1 3.6)) +(def alarm-tone true) +(def alarm-speed-threshold 0.5) +(def alarm-gyro-threshold 10) +(def alarm-voltage 24) +(def eco-speed (/ 7 3.6)) +(def eco-current 0.6) +(def eco-watts 400) +(def eco-fw 0) +(def drive-speed (/ 17 3.6)) +(def drive-current 0.7) +(def drive-watts 500) +(def drive-fw 0) +(def sport-speed (/ 22 3.6)) +(def sport-current 1.0) +(def sport-watts 700) +(def sport-fw 0) +(def secret-enabled true) +(def secret-eco-speed (/ 27 3.6)) +(def secret-eco-current 1.0) +(def secret-eco-watts 1000) +(def secret-eco-fw 0) +(def secret-drive-speed (/ 47 3.6)) +(def secret-drive-current 1.0) +(def secret-drive-watts 1500) +(def secret-drive-fw 0) +(def secret-sport-speed (/ 1000 3.6)) +(def secret-sport-current 1.0) +(def secret-sport-watts 2000) +(def secret-sport-fw 10) +(def apply-speed true) +(def apply-current true) +(def apply-watts true) +(def apply-fw true) +(def secret-presses 1) +(def secret-off-presses 3) +(def secret-off-combo 3) +(def secret-off-requires-lock false) +(def secret-combo 0) +(def secret-requires-lock false) +(def secret-exit-on-lock false) +(def lock-presses 2) +(def lock-combo 1) +(def mode-presses 2) +(def mode-combo 3) +(def mode-requires-lock false) +(def light-presses 1) +(def light-combo 3) +(def light-requires-lock false) +(def light-on-boot false) +(def light-offset-thr 0.0) +(def light-gain-thr 1.0) +(def light-offset-brk 0.0) +(def light-gain-brk 1.0) +(def boot-mode 1) +(def use-mph false) +(def bms-soc-enable false) +(def app-pin 0) +(def rear-light-enable false) +(def auto-taillight false) +(def brake-light-mode 1) +(def taillight-brightness 0.4) +(def tail-off-lock false) +(def head-off-lock false) +(def slip-erpm 1200.0) +(def slip-hold 2.0) +(def slip-time 0) +(def eco-om 1.0) +(def drive-om 1.0) +(def sport-om 1.0) +(def secret-eco-om 1.0) +(def secret-drive-om 1.0) +(def secret-sport-om 1.0) +(def apply-om false) +(def cruise-allow false) +(def cruise-enabled false) +(def cruise-delay 5.0) +(def cruise-deviation 1.0) +(def cruise-min-speed 5.0) +(def cruise-max-speed 35.0) +@const-start +(import "pkg@://vesc_packages/lib_code_server/code_server.vescpkg" 'code-server) +(read-eval-program code-server) +@const-end +(def model 2) +(def tx-base 7) +(def rx-hdr 0x5aa5) +(def rx-pre 4) +(def rx-o 1) +(def rx-sub 0) +(def tx-hdr-sum 0) +(def thr-idx 5) +(def brk-idx 6) +(def press-time (systime)) +(def presses 0) +(def off false) +(def lock false) +(def speedmode 4) +(def light false) +(def unlock false) +(def use-defaults false) +(def alarm 0) +(def alarm-time (systime)) +(def feedback 0) +(def beep-time (systime)) +(def last-rx (systime)) +(def rx-gap-avg 0.05) +(def lev-thr 0.0) +(def lev-brk 0.0) +(def lever-state 0) +(def lever-since (systime)) +(def lever-armed true) +(def can-ids '()) +(def can-ids-time 0) +(def gyro-src -1) +(def gyro-probe 0) +(def cur-speed-kmh 0.0) +(def cur-batt 0.0) +(def cur-vin 0.0) +(def cur-amps 0.0) +(def cur-bms-temp -100.0) +(def cur-range 0.0) +(def cur-trip 0) +(def cur-odo 0) +(def cur-runtime 0) +(def cur-fet 0.0) +(def cur-mot 0.0) +(def cur-fet-max 0.0) +(def cur-mot-max 0.0) +(def warn-temp false) +(def cur-maxkmh 0.0) +(def cur-cells 10) +(def cur-cap 0) +(def bms-active false) +(def bms-warn false) +(def app-boot-time (systime)) +(def app-cache-time (systime)) +(def app-slow-time (systime)) +(def app-slow-step 0) +(def cur-wh-tot 0.0) +(def b0-wanted false) +(def app-dst 0x3e) +(def g2-aux 0x40) +(def app-todo 0) +(def levers-active false) +(def app-pend false) +(def power-pin 0) +(def dash-power-held false) +(def pend-dev 0) +(def app-pend2 false) +(def pend2-dev 0) +(def pend2-reg 0) +(def pend2-n 0) +(def pend-reg 0) +(def pend-n 0) +(def thr-rest 255) +(def brk-rest 255) +(def dash-tx-iv 0.05) +(def dash-tx-time (systime)) +(def app-enable false) +(def cur-cell-mv 0) +(def cur-soh 100) +(def pwm-started false) +(def blink-state false) +(def blink-since (systime)) +(def cruising false) +(def cruise-thr-released false) +(def cruise-blocked false) +(def cruise-ref 0.0) +(def cruise-since (systime)) +(def cruise-shown-time (systime)) +(def cruise-v1-start 0.3) +(def thr-corr-v 0.0) +(def dash-thr-raw 0) +(def dash-brk-raw 0) +(def calib-stage 'idle) +(def calib-phase 0) +(def calib-slot 0) +(def calib-slots 4) +(def calib-channel 'thr) +(def calib-since (systime)) +(def calib-prep-duration 3.0) +(def calib-settle-duration 0.5) +(def calib-sample-duration 0.5) +(def calib-release-duration 3.0) +(def calib-off-sum 0.0) (def calib-off-cnt 0) +(def calib-on-sum 0.0) (def calib-on-cnt 0) +(def calib-off-rel 0.0) (def calib-on-rel 0.0) +@const-start +(def settings-version 420i32) +(def setting-defs '( +(ver-code 0 i 0) +(software-adc 1 b true) +(software-adc2 87 b true) +(tail-off-lock 95 b false) +(head-off-lock 96 b false) +(temp-warning-motor 4 f 80.0) +(temp-warning-fet 5 f 80.0) +(bms-temp-hi 97 f 50.0) +(bms-temp-lo 98 f 0.0) +(show-batt-in-idle 6 b false) +(min-speed-kmh 7 f 1.0) +(alarm-tone 8 b true) +(alarm-speed-threshold 9 f 0.5) +(alarm-gyro-threshold 10 f 10.0) +(alarm-voltage 11 f 24.0) +(eco-speed-kmh 12 f 7.0) +(eco-current 13 f 0.6) +(eco-watts 14 f 400.0) +(eco-fw 15 f 0.0) +(drive-speed-kmh 16 f 17.0) +(drive-current 17 f 0.7) +(drive-watts 18 f 500.0) +(drive-fw 19 f 0.0) +(sport-speed-kmh 20 f 22.0) +(sport-current 21 f 1.0) +(sport-watts 22 f 700.0) +(sport-fw 23 f 0.0) +(secret-enabled 24 b true) +(secret-eco-speed-kmh 25 f 27.0) +(secret-eco-current 26 f 1.0) +(secret-eco-watts 27 f 1000.0) +(secret-eco-fw 28 f 0.0) +(secret-drive-speed-kmh 29 f 47.0) +(secret-drive-current 30 f 1.0) +(secret-drive-watts 31 f 1500.0) +(secret-drive-fw 32 f 0.0) +(secret-sport-speed-kmh 33 f 1000.0) +(secret-sport-current 34 f 1.0) +(secret-sport-watts 35 f 2000.0) +(secret-sport-fw 36 f 10.0) +(model 37 i 2) +(apply-speed 38 b true) +(apply-current 39 b true) +(apply-watts 40 b true) +(apply-fw 41 b true) +(secret-presses 42 i 1) +(secret-off-presses 89 i 3) +(secret-off-combo 90 i 3) +(secret-off-requires-lock 91 b false) +(idle-display 92 i 0) +(secret-combo 43 i 0) +(secret-requires-lock 44 b false) +(lock-presses 45 i 2) +(lock-combo 46 i 1) +(mode-presses 51 i 2) +(mode-combo 52 i 3) +(light-presses 53 i 1) +(light-combo 54 i 3) +(light-on-boot 55 b false) +(button-speed-kmh 56 f 0.1) +(boot-mode 57 i 1) +(show-batt-idle-secret 58 b false) +(mode-requires-lock 59 b false) +(light-requires-lock 60 b false) +(use-mph 61 b false) +(rear-light-enable 62 b false) +(auto-taillight 63 b false) +(brake-light-mode 64 i 1) +(eco-om 65 f 1.0) +(drive-om 66 f 1.0) +(sport-om 67 f 1.0) +(secret-eco-om 68 f 1.0) +(secret-drive-om 69 f 1.0) +(secret-sport-om 70 f 1.0) +(apply-om 71 b false) +(bms-soc-enable 73 b false) +(cruise-enabled 74 b false) +(cruise-delay 75 f 5.0) +(cruise-deviation 76 f 1.0) +(cruise-min-speed 82 f 5.0) +(cruise-max-speed 83 f 35.0) +(secret-exit-on-lock 77 b false) +(light-offset-thr 78 f 0.0) +(light-offset-brk 79 f 0.0) +(light-gain-thr 80 f 1.0) +(light-gain-brk 81 f 1.0) +(app-pin 84 i 0) +(taillight-brightness 94 f 0.4) +(app-enable 85 b false) +(power-pin 88 i 0) +(cruise-allow 93 b false) +)) +(def setting-groups '( +(misc light-on-boot button-speed-kmh use-mph bms-soc-enable secret-exit-on-lock +light-offset-thr light-gain-thr light-offset-brk light-gain-brk app-pin power-pin +head-off-lock) +(general software-adc software-adc2 show-batt-in-idle show-batt-idle-secret +min-speed-kmh app-enable idle-display) +(temps temp-warning-motor temp-warning-fet bms-temp-hi bms-temp-lo) +(modes eco-speed-kmh eco-current eco-watts eco-fw +drive-speed-kmh drive-current drive-watts drive-fw +sport-speed-kmh sport-current sport-watts sport-fw +boot-mode eco-om drive-om sport-om) +(secret secret-enabled +secret-eco-speed-kmh secret-eco-current secret-eco-watts secret-eco-fw +secret-drive-speed-kmh secret-drive-current secret-drive-watts secret-drive-fw +secret-sport-speed-kmh secret-sport-current secret-sport-watts secret-sport-fw +secret-eco-om secret-drive-om secret-sport-om) +(apply apply-speed apply-current apply-watts apply-fw apply-om) +(gesture secret-presses secret-combo secret-requires-lock +lock-presses lock-combo +mode-presses mode-combo mode-requires-lock +light-presses light-combo light-requires-lock +secret-off-presses secret-off-combo secret-off-requires-lock) +(rear rear-light-enable auto-taillight brake-light-mode taillight-brightness +tail-off-lock) +(cruise cruise-allow cruise-delay cruise-deviation cruise-min-speed cruise-max-speed) +(alarm alarm-tone alarm-speed-threshold alarm-gyro-threshold alarm-voltage) +)) +(def speed-settings '(min-speed-kmh button-speed-kmh eco-speed-kmh drive-speed-kmh +sport-speed-kmh secret-eco-speed-kmh secret-drive-speed-kmh secret-sport-speed-kmh)) +(def last-button-state false) +(defun read-setting (name) +{ +(var d (assoc setting-defs name)) +(var ty (second d)) +(var v (if (eq ty 'f) (eeprom-read-f (first d)) (eeprom-read-i (first d)))) +(if (eq v nil) 'unset (if (eq ty 'b) (!= v 0) v)) +} +) +(defun write-setting (name val) +{ +(var d (assoc setting-defs name)) +(var addr (first d)) +(var ty (second d)) +(var new (if (eq ty 'b) (if val 1 0) val)) +(var dflt (let ((x (ix d 2))) (if (eq ty 'b) (if x 1 0) x))) +(var cur (if (eq ty 'f) (eeprom-read-f addr) (eeprom-read-i addr))) +(if (if (eq cur nil) (!= new dflt) (!= cur new)) +{ +(if (eq ty 'f) (eeprom-store-f addr new) (eeprom-store-i addr new)) +(sleep 0.05) +} +) +} +) +(defun valid-model (m) +(and (not (eq m 'unset)) (not (eq m nil)) (>= m 0) (<= m 3)) +) +(defun restore-defaults () +{ +(write-setting 'ver-code settings-version) +(write-setting 'model 2) +(loopforeach s setting-defs +(let ((n (ix s 0))) +(if (not (or (eq n 'model) (eq n 'ver-code))) +(write-setting n (ix s 3))))) +} +) +(defun load-settings () +{ +(if (not-eq (read-setting 'ver-code) settings-version) +{ +(write-setting 'ver-code settings-version) +(write-setting 'model 2) +} +) +(loopforeach g setting-groups +(loopforeach n (cdr g) +(if (not (member n speed-settings)) +(set n (let ((v (read-setting n))) (if (eq v 'unset) (eval n) v)))))) +(set 'cruise-enabled (let ((v (read-setting 'cruise-enabled))) (if (eq v 'unset) cruise-enabled v))) +(set 'min-speed (setting-def 'min-speed-kmh)) +(set 'eco-speed (/ (setting-def 'eco-speed-kmh) 3.6)) +(set 'drive-speed (/ (setting-def 'drive-speed-kmh) 3.6)) +(set 'sport-speed (/ (setting-def 'sport-speed-kmh) 3.6)) +(set 'secret-eco-speed (/ (setting-def 'secret-eco-speed-kmh) 3.6)) +(set 'secret-drive-speed (/ (setting-def 'secret-drive-speed-kmh) 3.6)) +(set 'secret-sport-speed (/ (setting-def 'secret-sport-speed-kmh) 3.6)) +(set 'button-safety-speed (/ (setting-def 'button-speed-kmh) 3.6)) +(if (< light-gain-thr 0.05) (set 'light-gain-thr 1.0)) +(if (< light-gain-brk 0.05) (set 'light-gain-brk 1.0)) +(var m (read-setting 'model)) +(if (not (valid-model m)) { +(setq m 2) +(write-setting 'model m) +}) +(set 'model m) +} +) +(defun apply-software-adc () +(let ((d1 (or software-adc (= power-pin 1))) +(d2 (or software-adc2 (= power-pin 2)))) +{ +(let ((d (cond ((and d1 d2) 1) (d1 2) (d2 3) (t 0)))) +(if (= d 0) +(app-adc-detach 2 1) +(app-adc-detach 3 d) +) +) +(if (and d1 (not software-adc)) (app-adc-override 0 0)) +(if (and d2 (not software-adc2)) (app-adc-override 1 0)) +(if (and (not software-adc) (not software-adc2) (not (eq calib-stage 'idle))) { +(set 'calib-stage 'idle) +(send-data "calib-aborted") +}) +} +) +) +(defun apply-dash-power () +(let ((pin (if (= power-pin 1) 'pin-adc1 'pin-adc2))) +(if (> power-pin 0) +(if (not (eq dash-power-held pin)) { +(if dash-power-held (gpio-configure dash-power-held 'pin-mode-analog)) +(gpio-write pin (if off 0 1)) +(gpio-configure pin 'pin-mode-out) +(set 'dash-power-held pin) +}) +(if dash-power-held { +(gpio-configure dash-power-held 'pin-mode-analog) +(set 'dash-power-held false) +}) +) +) +) +(defun apply-runtime-settings () +{ +(load-settings) +(trap (let ((v (* 0.4 (conf-get 'adc-tc-max-diff)))) (if (> v 60) (setq slip-erpm v)))) +(if (!= model 2) { +(apply-software-adc) +(apply-dash-power) +(apply-mode) +(if rear-light-enable +(if (not pwm-started) { +(pwm-start 200 0) +(set 'pwm-started true) +}) +(if pwm-started (pwm-set-duty 0.0)) +) +}) +} +) +(defun save-group (g vals) +{ +(var i 0) +(loopforeach n (assoc setting-groups g) +{ +(write-setting n (ix vals i)) +(setq i (+ i 1)) +} +) +(send-data "ack") +} +) +(defun save-model (m) +{ +(write-setting 'model (if (valid-model m) m 2)) +(send-data "model-ok") +} +) +(defun finish-settings-save () +{ +(apply-runtime-settings) +(send-settings) +(send-data "ok") +} +) +(defun restore-settings-ui () +{ +(restore-defaults) +(apply-runtime-settings) +(send-data "reset-ok") +} +) +(defun ctrl-lock (on) +(if (and (not (eq on lock)) (<= (abs (get-speed)) 1.0)) +(toggle-lock) +) +) +(defun ctrl-light (on) +(set 'light on) +) +(defun ctrl-mode (m) +(if (or (= m 1) (= m 2) (= m 4)) { +(set 'speedmode m) +(apply-mode) +}) +) +(defun ctrl-secret (on) +(if (and secret-enabled (not (eq on unlock))) +(toggle-secret) +) +) +(defun ctrl-cruise (on) +{ +(set 'cruise-enabled (and on cruise-allow)) +(write-setting 'cruise-enabled cruise-enabled) +(if (not cruise-enabled) (cruise-cancel)) +} +) +(defun boot-state () +{ +(set 'speedmode (if (or (= boot-mode 1) (= boot-mode 2) (= boot-mode 4)) boot-mode 4)) +(set 'light light-on-boot) +} +) +(defun ctrl-power (on) +(if on +(if off { +(set 'off false) +(set 'feedback 1) +(set 'unlock false) +(boot-state) +(apply-mode) +(stats-reset) +}) +(if (and (not off) (not lock) (<= (abs (get-speed)) 1.0)) { +(set 'light false) +(set 'feedback 1) +(set 'unlock false) +(apply-mode) +(set 'off true) +}) +) +) +(defun tf (v) (if v "true " "false ")) +(defun send-state () +(let ((whkm (send-state-whkm))) +(send-data (str-merge +"state " +(tf off) +(tf lock) +(tf light) +(tf unlock) +(str-from-n speedmode "%d ") +(str-from-n cur-batt "%.0f ") +(str-from-n cur-vin "%.1f ") +(str-from-n (abs cur-speed-kmh) "%.1f ") +(str-from-n (* cur-vin cur-amps) "%.0f ") +(str-from-n whkm "%.1f ") +(str-from-n cur-range "%.1f ") +(str-from-n cur-amps "%.1f ") +(str-from-n (send-state-maxkmh) "%.0f ") +(tf cruising) +(tf cruise-enabled) +(tf cruise-allow) +(tf img-ok) +(str-from-n cur-fet-max "%.0f ") +(str-from-n cur-mot-max "%.0f ") +(tf secret-enabled) +(str-from-n (+ 1 (length (can-devs))) "%d ") +(str-from-n (get-fault) "%d ") +(str-from-n (wheel-slip) "%d ") +(str-from-n cur-bms-temp "%.0f ") +(str-from-n (secs-since app-boot-time) "%.0f") +)) +) +) +(defun send-state-maxkmh () +(* 3.6 (if unlock +(cond ((= speedmode 1) secret-drive-speed) ((= speedmode 2) secret-eco-speed) (t secret-sport-speed)) +(cond ((= speedmode 1) drive-speed) ((= speedmode 2) eco-speed) (t sport-speed)) +)) +) +(defun send-state-whkm () { +(var dist-km (/ (get-dist-abs) 1000.0)) +(if (> dist-km 0.05) +(/ (- (setup-wh) (setup-wh-chg)) dist-km) +0.0 +) +}) +(defun send-state-range () { +(var whkm (send-state-whkm)) +(if (> whkm 0.1) +(/ (* (get-batt) cur-wh-tot) whkm) +0.0 +) +}) +(defun setting-def (n) +(let ((v (if use-defaults 'unset (read-setting n)))) +(if (eq v 'unset) (ix (assoc setting-defs n) 2) v)) +) +(defun setting-str (n) +(let ((d (assoc setting-defs n)) (v (setting-def n))) +{ +(cond +((eq (second d) 'b) (if v "true" "false")) +((eq (second d) 'f) (str-from-n v "%.3f")) +(t (str-from-n v "%d")) +) +} +) +) +(defun send-groups () +(loopforeach g setting-groups +{ +(var line (to-str (first g))) +(loopforeach n (cdr g) +(setq line (str-merge line " " (setting-str n)))) +(send-data line) +(sleep 0.05) +} +) +) +(defun send-settings () +{ +(send-data (str-merge "model " (setting-str 'model))) +(sleep 0.05) +(send-groups) +} +) +(defun send-defaults () +{ +(set 'use-defaults true) +(send-groups) +(set 'use-defaults false) +} +) +(defun event-handler () +(loopwhile t +(recv +((event-data-rx . (? data)) (trap (eval (read data)))) +(_ nil) +))) +@const-end +@const-start +(defun calib-reset-accum() +{ +(set 'calib-off-sum 0.0) (set 'calib-off-cnt 0) +(set 'calib-on-sum 0.0) (set 'calib-on-cnt 0) +} +) +(defun calib-slot-light(slot) (= (mod slot 2) 1)) +(defun calib-add(val) +(if (calib-slot-light calib-slot) +{ (set 'calib-on-sum (+ calib-on-sum val)) (set 'calib-on-cnt (+ calib-on-cnt 1)) } +{ (set 'calib-off-sum (+ calib-off-sum val)) (set 'calib-off-cnt (+ calib-off-cnt 1)) } +) +) +(defun calib-off-avg() (if (> calib-off-cnt 0) (/ calib-off-sum calib-off-cnt) 0.0)) +(defun calib-on-avg() (if (> calib-on-cnt 0) (/ calib-on-sum calib-on-cnt) 0.0)) +(defun calib-finish-regression(label off-rel off-full on-rel on-full) +{ +(var off-range (- off-full off-rel)) +(if (< off-range 0.3) +(send-data (str-merge "calib-error " label)) +{ +(var gain (/ (- on-full on-rel) off-range)) +(var offset (- on-rel (* gain off-rel))) +(if (< gain 0.3) (setq gain 0.3)) +(if (> gain 3.0) (setq gain 3.0)) +(if (< offset -1.5) (setq offset -1.5)) +(if (> offset 1.5) (setq offset 1.5)) +(send-data (str-merge "calib-result " label " " +(str-from-n offset "%.3f ") (str-from-n gain "%.3f ") +(str-from-n off-rel "%.3f ") (str-from-n off-full "%.3f ") +(str-from-n on-rel "%.3f ") (str-from-n on-full "%.3f"))) +} +) +} +) +(defun calib-phase-label(phase) (if (= phase 0) "rel" "full")) +(defun calib-slot-index() (+ (* calib-phase calib-slots) calib-slot 1)) +(defun calib-slot-total() (* calib-slots 2)) +(defun calib-slot-progress() +(str-merge (str-from-n (calib-slot-index) "%d") " " (str-from-n (calib-slot-total) "%d")) +) +(defun calib-can-start() (and (not off) (<= (abs (get-speed)) 1.0))) +(defun calib-begin-prep(phase) +{ +(set 'calib-phase phase) +(set 'calib-slot 0) +(set 'light false) +(calib-reset-accum) +(set 'calib-stage 'prep) +(set 'calib-since (systime)) +(send-data (str-merge "calib-stage prep " (calib-phase-label phase))) +} +) +(defun calib-begin-settle() +{ +(set 'light (calib-slot-light calib-slot)) +(set 'calib-stage 'settle) +(set 'calib-since (systime)) +(send-data (str-merge "calib-stage settle " (calib-phase-label calib-phase) " " (calib-slot-progress))) +} +) +(defun calib-begin-sample() +{ +(set 'calib-stage 'measure) +(set 'calib-since (systime)) +(send-data (str-merge "calib-stage measure " (calib-phase-label calib-phase) " " (calib-slot-progress))) +} +) +(defun calib-finish-slot() +{ +(set 'calib-slot (+ calib-slot 1)) +(if (< calib-slot calib-slots) +(calib-begin-settle) +{ +(var off-avg (calib-off-avg)) +(var on-avg (calib-on-avg)) +(var ch calib-channel) +(send-data (str-merge "calib-progress " (if (eq ch 'thr) "thr " "brk ") +(calib-phase-label calib-phase) " " +(str-from-n off-avg "%.3f ") (str-from-n on-avg "%.3f"))) +(if (= calib-phase 0) +{ +(set 'calib-off-rel off-avg) +(set 'calib-on-rel on-avg) +(calib-begin-prep 1) +} +{ +(set 'light false) +(calib-finish-regression (if (eq ch 'thr) "thr" "brk") +calib-off-rel off-avg calib-on-rel on-avg) +(set 'calib-stage 'release) +(set 'calib-since (systime)) +(send-data (str-merge "calib-stage release " (if (eq ch 'thr) "thr" "brk"))) +} +) +} +) +} +) +(defun calib-start-channel(channel) +(if (calib-can-start) +{ +(set 'calib-channel channel) +(calib-begin-prep 0) +} +(send-data "calib-refused") +) +) +(defun calib-start-thr() (calib-start-channel 'thr)) +(defun calib-start-brk() (calib-start-channel 'brk)) +(defunret adc-input (off) +{ +(if (not (or software-adc software-adc2)) (return nil)) +(let ((gap (secs-since last-rx))) +(if (< gap 1.0) +(if (> gap rx-gap-avg) +(set 'rx-gap-avg gap) +(set 'rx-gap-avg (+ (* 0.98 rx-gap-avg) (* 0.02 gap))) +) +) +) +(set 'last-rx (systime)) +(set 'dash-thr-raw (bufget-u8 uart-buf (+ thr-idx off))) +(set 'dash-brk-raw (bufget-u8 uart-buf (+ brk-idx off))) +(var thr-raw-v (/ dash-thr-raw 77.2)) +(var brk-raw-v (/ dash-brk-raw 77.2)) +(var throttle (if light (/ (- thr-raw-v light-offset-thr) light-gain-thr) thr-raw-v)) +(var brake (if light (/ (- brk-raw-v light-offset-brk) light-gain-brk) brk-raw-v)) +(if (and (> dash-thr-raw 16) (< dash-thr-raw thr-rest)) (set 'thr-rest dash-thr-raw)) +(if (and (> dash-brk-raw 16) (< dash-brk-raw brk-rest)) (set 'brk-rest dash-brk-raw)) +(set 'levers-active (or (> dash-thr-raw (+ thr-rest 10)) +(> dash-brk-raw (+ brk-rest 10)))) +(if (< throttle 0.0) (setq throttle 0.0)) +(if (> throttle 3.3) (setq throttle 3.3)) +(if (< brake 0.0) (setq brake 0.0)) +(if (> brake 3.3) (setq brake 3.3)) +(set 'thr-corr-v throttle) +(if (and cruising (not cruise-thr-released)) (setq throttle 0.0)) +(if (eq calib-stage 'idle) +{ +(if software-adc (app-adc-override 0 throttle)) +(if software-adc2 (app-adc-override 1 brake)) +} +{ +(if software-adc (app-adc-override 0 0)) +(if software-adc2 (app-adc-override 1 0)) +} +) +(if (and (not (eq calib-stage 'idle)) (not (eq calib-stage 'release)) (not (calib-can-start))) +{ +(set 'calib-stage 'idle) +(send-data "calib-aborted") +} +(cond +((eq calib-stage 'prep) +(if (> (secs-since calib-since) calib-prep-duration) (calib-begin-settle)) +) +((eq calib-stage 'settle) +(if (> (secs-since calib-since) calib-settle-duration) (calib-begin-sample)) +) +((eq calib-stage 'measure) +(if (> (secs-since calib-since) calib-sample-duration) +(calib-finish-slot) +(calib-add (if (eq calib-channel 'thr) thr-raw-v brk-raw-v)) +) +) +((eq calib-stage 'release) +(if (> (secs-since calib-since) calib-release-duration) +{ +(set 'calib-stage 'idle) +(send-data (str-merge "calib-stage idle " (if (eq calib-channel 'thr) "thr" "brk"))) +} +) +) +) +) +} +) +(defun handle-taillight() +(if rear-light-enable { +(var live (and (not off) (not (and lock tail-off-lock)))) +(var base (and live (or light auto-taillight))) +(var braking (and live (> lev-brk adc-touch))) +(pwm-set-duty +(if braking +(cond +((= brake-light-mode 1) 1.0) +((= brake-light-mode 2) { +(if (> (secs-since blink-since) 0.15) { +(set 'blink-state (not blink-state)) +(set 'blink-since (systime)) +}) +(if blink-state 1.0 0.0) +}) +(t (if base taillight-brightness 0.0)) +) +(if base taillight-brightness 0.0) +) +) +}) +) +(defun cruise-cancel() +(if cruising { +(set 'cruising false) +(set 'cruise-blocked true) +(app-adc-override 3 0) +(set 'feedback 1) +}) +) +(defun handle-cruise(speed-kmh) +(if (and cruise-allow cruise-enabled (not off) (not lock)) +{ +(var thr lev-thr) +(var brk lev-brk) +(if cruising +{ +(if (if software-adc +(< thr-corr-v cruise-v1-start) +(<= thr adc-touch)) +(set 'cruise-thr-released true) +) +(if (or (and cruise-thr-released (> thr adc-touch)) +(> brk adc-touch)) +(cruise-cancel) +) +} +(if (<= thr adc-touch) +{ +(set 'cruise-blocked false) +(set 'cruise-ref speed-kmh) +(set 'cruise-since (systime)) +} +(if (and (not cruise-blocked) (>= speed-kmh min-speed) +(>= speed-kmh cruise-min-speed) (<= speed-kmh cruise-max-speed)) +{ +(if (> (abs (- speed-kmh cruise-ref)) cruise-deviation) { +(set 'cruise-ref speed-kmh) +(set 'cruise-since (systime)) +}) +(if (> (secs-since cruise-since) cruise-delay) { +(set 'cruising true) +(set 'cruise-thr-released false) +(set 'cruise-v1-start (conf-get 'adc-v1-start)) +(set 'cruise-shown-time (systime)) +(app-adc-override 3 1) +(set 'feedback 2) +(set 'cruise-since (systime)) +}) +} +{ +(set 'cruise-ref speed-kmh) +(set 'cruise-since (systime)) +} +) +) +) +} +(cruise-cancel) +) +) +(defun handle-features() +{ +(set 'cur-speed-kmh (* (get-lowest-speed) 3.6)) +(check-slip) +(trap +(if (and bms-soc-enable (> (get-bms-val 'bms-soc) 0)) +{ +(set 'bms-active true) +(set 'cur-batt (* (get-bms-val 'bms-soc) 100)) +(var bt (get-bms-val 'bms-temp-cell-max)) +(set 'cur-bms-temp bt) +(set 'bms-warn (or (> bt bms-temp-hi) (< bt bms-temp-lo))) +} +{ +(set 'cur-batt (* (get-batt) 100)) +(set 'bms-active false) +(set 'bms-warn false) +(set 'cur-bms-temp -100.0) +} +) +) +(var current-speed cur-speed-kmh) +(var wd (* 8 rx-gap-avg)) +(if (< wd 0.5) (setq wd 0.5)) +(if (> wd 1.5) (setq wd 1.5)) +(if (and (or software-adc software-adc2) (> (secs-since last-rx) wd)) +(cruise-cancel) +) +(trap (handle-cruise current-speed)) +(if (or off lock (< current-speed min-speed)) +(if (not (app-is-output-disabled)) +{ +(app-adc-override 0 0) +(app-adc-override 1 0) +(app-disable-output -1) +(stop-current) +} +) +(if (app-is-output-disabled) +(app-disable-output 0) +) +) +(trap (app-run-todo)) +(trap (app-cache-update)) +(trap (handle-taillight)) +(if dash-power-held +(gpio-write dash-power-held +(if (and off (or (and (= feedback 0) (> (secs-since beep-time) 0.15)) +(> (secs-since last-rx) 0.5))) +0 1))) +(if (and (not software-adc) (not software-adc2)) { +(if (= power-pin 1) (app-adc-override 0 0)) +(if (= power-pin 2) (app-adc-override 1 0)) +}) +(handle-lock (abs current-speed)) +} +) +(defun highest-temp (local canget) +(let ((hi local)) +{ +(trap (loopforeach i (can-devs) +(let ((c (canget i))) (if (> c hi) (setq hi c))))) +hi +} +) +) +(defun idle-value () +(cond +((= idle-display 1) (rnd cur-vin)) +((= idle-display 2) (rnd cur-fet-max)) +((= idle-display 3) (rnd cur-mot-max)) +(t (rnd cur-batt)) +) +) +(defun build-dash-frame () +{ +(var current-speed (abs cur-speed-kmh)) +(var disp-speed (rnd (if use-mph (* current-speed 0.621371) current-speed))) +(var battery (rnd cur-batt)) +(var mode-base (+ (if (= speedmode 1) 0 speedmode) (if use-mph 64 0))) +(if (= model 3) +(bufset-u8 tx-frame tx-base (+ mode-base +(if off 16 0) +(if lock 32 0) +(if (or warn-temp bms-warn) 128 0))) +(if off +(bufset-u8 tx-frame tx-base 16) +(if lock +(bufset-u8 tx-frame tx-base 32) +(if (or warn-temp bms-warn) +(bufset-u8 tx-frame tx-base (+ 128 mode-base)) +(bufset-u8 tx-frame tx-base mode-base) +) +) +) +) +(if lock +(bufset-u8 tx-frame (+ tx-base 1) 0) +(bufset-u8 tx-frame (+ tx-base 1) battery) +) +(var lamp (if (= model 1) 100 1)) +(if (not off) +(if (> alarm 4) +(bufset-u8 tx-frame (+ tx-base 2) lamp) +(bufset-u8 tx-frame (+ tx-base 2) (+ (if (and light (or (not-eq calib-stage 'idle) (not (and lock head-off-lock)))) lamp 0) +(if (and (= model 3) cruising) 4 0))) +) +(bufset-u8 tx-frame (+ tx-base 2) 0) +) +(if lock +(bufset-u8 tx-frame (+ tx-base 4) 0) +(if (and cruising (< (secs-since cruise-shown-time) 3)) +(bufset-u8 tx-frame (+ tx-base 4) 125) +(if (if unlock show-batt-idle-secret show-batt-in-idle) +(if (> current-speed 1) +(bufset-u8 tx-frame (+ tx-base 4) disp-speed) +(bufset-u8 tx-frame (+ tx-base 4) (idle-value))) +(bufset-u8 tx-frame (+ tx-base 4) disp-speed) +) +) +) +(if (> alarm 0) +(bufset-u8 tx-frame (+ tx-base 5) 99) +(bufset-u8 tx-frame (+ tx-base 5) (get-fault)) +) +} +) +(defun seal-dash-frame () +{ +(trap (build-dash-frame)) +(if (> feedback 0) +{ +(bufset-u8 tx-frame (+ tx-base 3) 1) +(set 'feedback (- feedback 1)) +(set 'beep-time (systime)) +} +(bufset-u8 tx-frame (+ tx-base 3) 0) +) +(var crc-end (- (buflen tx-frame) 2)) +(var crcout tx-hdr-sum) +(looprange i tx-base crc-end +(setq crcout (+ crcout (bufget-u8 tx-frame i)))) +(setq crcout (bitwise-xor crcout 0xFFFF)) +(bufset-u8 tx-frame crc-end crcout) +(bufset-u8 tx-frame (+ crc-end 1) (shr crcout 8)) +} +) +(defun update-dash () { (seal-dash-frame) (uart-write tx-frame) }) +(def app-ver 0x0700) +(defun app-build-serial () +(let ((n 0)) +{ +(trap (loopforeach b (sysinfo 'uuid) (setq n (+ (* n 31) b)))) +(var lo (mod (bitwise-and n 0x7FFFFFFF) 100000)) +(var hi (mod (bitwise-and (shr n 7) 0x7FFFFFFF) 100000)) +(var s (str-merge "VESC" (str-from-n hi "%05d") (str-from-n lo "%05d"))) +(looprange i 0 14 (bufset-u8 app-serial i (bufget-u8 s i))) +(looprange i 0 5 (bufset-u8 app-cpuid i +(+ (shl (- (bufget-u8 s (+ 4 (* i 2))) 48) 4) +(- (bufget-u8 s (+ 5 (* i 2))) 48)))) +} +) +) +(defun app-build-pin () +(let ((s (str-from-n app-pin "%06d"))) +(looprange i 0 6 (bufset-u8 app-pin-buf i (bufget-u8 s i))) +) +) +(defun app-word (buf idx) +(+ (bufget-u8 buf (* idx 2)) (shl (bufget-u8 buf (+ (* idx 2) 1)) 8)) +) +(defun rnd (v) (to-i (if (< v 0) (- v 0.5) (+ v 0.5)))) +(defun app-clamp16 (v) +(cond ((> v 32767) 32767) ((< v -32768) -32768) (t (rnd v))) +) +(defun app-sysinfo (key) (let ((v 0)) { (trap (setq v (sysinfo key))) (to-i v) })) +(defun app-cache-update () +{ +(if (> (secs-since app-cache-time) 0.2) { +(set 'app-cache-time (systime)) +(set 'cur-vin (get-vin)) +(set 'cur-amps (setup-current-in)) +(set 'cur-fet (get-temp-fet)) +(set 'cur-mot (get-temp-mot)) +(set 'cur-fet-max (highest-temp cur-fet canget-temp-fet)) +(set 'cur-mot-max (highest-temp cur-mot canget-temp-motor)) +(set 'warn-temp (or (> cur-fet-max temp-warning-fet) +(> cur-mot-max temp-warning-motor))) +(set 'cur-cell-mv (app-clamp16 (/ (* cur-vin 1000) cur-cells))) +(if bms-active +(trap { +(set 'cur-soh (app-clamp16 (* (get-bms-val 'bms-soh) 100))) +(looprange i 0 (if (> cur-cells 15) 15 cur-cells) { +(var mv (app-clamp16 (* (get-bms-val 'bms-v-cell i) 1000))) +(bufset-u8 app-cells (* i 2) (bitwise-and mv 0xFF)) +(bufset-u8 app-cells (+ (* i 2) 1) (bitwise-and (shr mv 8) 0xFF)) +}) +}) +) +(if (!= model 1) +{ +(build-app-frame app-f-b4 0xb4 6) +(build-app-frame app-f-7b 0x7b 6) +(build-app-frame app-f-75 0x75 2) +(build-app-frame-from app-f-33 0 0x22 0x33 4 true) +(build-app-frame-from app-f-35 0 0x22 0x35 2 true) +} +) +}) +(if (> (secs-since app-slow-time) 0.5) { +(set 'app-slow-time (systime)) +(cond +((= app-slow-step 0) { +(set 'cur-wh-tot (* 0.85 (conf-get 'si-battery-ah) (* 3.7 (conf-get 'si-battery-cells)))) +(set 'cur-range (send-state-range)) +(set 'cur-trip (to-i (get-dist-abs))) +}) +((= app-slow-step 1) { +(set 'cur-odo (app-sysinfo 'odometer)) +(set 'cur-runtime (app-sysinfo 'runtime)) +(set 'cur-maxkmh (send-state-maxkmh)) +}) +((= app-slow-step 2) +(if (!= model 1) { +(build-app-frame-from app-f-31 0 0x22 0x31 2 true) +(build-app-frame-from app-f-40 0 0x22 0x40 30 true) +(build-app-frame app-f-25 0x25 2) +}) +) +(t (if (and b0-wanted (!= model 1)) { +(set 'b0-wanted false) +(build-app-frame app-f-b0 0xb0 52) +})) +) +(set 'app-slow-step (mod (+ app-slow-step 1) 4)) +}) +} +) +(defun app-speed-01 () (app-clamp16 (* (abs cur-speed-kmh) 10))) +(defun app-trip-m () cur-trip) +(defun app-volt-cv () (app-clamp16 (* cur-vin 100))) +(defun app-amp-ca () (app-clamp16 (* cur-amps 100))) +(defun app-range-10m () (app-clamp16 (* cur-range 100))) +(defun app-fet-01 () (app-clamp16 (* cur-fet 10))) +(defun app-cell-mv (i) (if bms-active (app-word app-cells i) cur-cell-mv)) +(defunret app-pend-buf () +{ +(if (= pend-dev 0x22) +(return (cond +((and (= pend-reg 0x33) (= pend-n 4)) app-f-33) +((and (= pend-reg 0x35) (= pend-n 2)) app-f-35) +((and (= pend-reg 0x31) (= pend-n 2)) app-f-31) +((and (= pend-reg 0x40) (= pend-n 30)) app-f-40) +(t nil) +)) +) +(cond +((and (= pend-reg 0xb4) (= pend-n 6)) app-f-b4) +((and (= pend-reg 0xb0) (= pend-n 52)) app-f-b0) +((and (= pend-reg 0x7b) (= pend-n 6)) app-f-7b) +((and (= pend-reg 0x1a) (= pend-n 2)) app-f-1a) +((and (= pend-reg 0x25) (= pend-n 2)) app-f-25) +((and (= pend-reg 0x3b) (= pend-n 2)) app-f-3b) +((and (= pend-reg 0x75) (= pend-n 2)) app-f-75) +((and (= pend-reg 0xda) (= pend-n 12)) app-f-da) +(t nil) +) +} +) +(defun combo-buf (al) +(cond ((= al 11) combo11) ((= al 13) combo13) ((= al 15) combo15) +((= al 21) combo21) ((= al 39) combo39) ((= al 61) combo61) (t nil)) +) +(defun send-dash-and-app () +(let ((ab (if (or (= model 1) app-pend2) nil (app-pend-buf)))) +{ +(set 'app-pend false) +(seal-dash-frame) +(if app-pend2 +{ +(set 'app-pend2 false) +(var dl (buflen tx-frame)) +(var l1 (app-fr-len pend-n)) +(var cb (array-create (+ dl l1 (app-fr-len pend2-n)))) +(trap { +(bufcpy cb 0 tx-frame 0 dl) +(build-app-frame-from cb dl pend-dev pend-reg pend-n (= pend-dev 0x22)) +(build-app-frame-from cb (+ dl l1) pend2-dev pend2-reg pend2-n +(= pend2-dev 0x22)) +(uart-write cb) +}) +(free cb) +} +(if (eq ab nil) +{ +(var dl (buflen tx-frame)) +(var cb (array-create (+ dl (app-fr-len pend-n)))) +(trap { +(bufcpy cb 0 tx-frame 0 dl) +(build-app-frame-from cb dl pend-dev pend-reg pend-n (= pend-dev 0x22)) +(uart-write cb) +}) +(free cb) +} +(let ((cb (combo-buf (buflen ab))) (dl (buflen tx-frame))) +(if (eq cb nil) +{ (uart-write tx-frame) (uart-write ab) } +{ +(bufcpy cb 0 tx-frame 0 dl) +(bufcpy cb dl ab 0 (buflen ab)) +(uart-write cb) +} +) +) +)) +} +) +) +(defun build-app-frame-from (buf at dev reg n bms) +{ +(var xm (= model 1)) +(var p (+ at (if xm 6 7))) +(var crc (+ n reg)) +(if xm +(let ((a (if bms 0x25 0x23))) +{ +(bufset-u16 buf at 0x55aa) +(bufset-u8 buf (+ at 2) (+ n 2)) +(bufset-u8 buf (+ at 3) a) +(bufset-u8 buf (+ at 4) 0x01) +(setq crc (+ crc 2 a 0x01)) +} +) +{ +(bufset-u16 buf at 0x5aa5) +(bufset-u8 buf (+ at 2) n) +(bufset-u8 buf (+ at 3) dev) +(bufset-u8 buf (+ at 4) app-dst) +(bufset-u8 buf (+ at 5) 0x04) +(setq crc (+ crc dev app-dst 0x04)) +} +) +(bufset-u8 buf (- p 1) reg) +(looprange i 0 (/ n 2) { +(var w (if bms (xm-bms-word (+ reg i)) +(if xm (xm-word (+ reg i)) (nb-word (+ reg i))))) +(var lo (bitwise-and w 0xFF)) +(var hi (bitwise-and (shr w 8) 0xFF)) +(bufset-u8 buf (+ p (* i 2)) lo) +(bufset-u8 buf (+ p (* i 2) 1) hi) +(setq crc (+ crc lo hi)) +}) +(setq crc (bitwise-xor crc 0xFFFF)) +(bufset-u8 buf (+ p n) (bitwise-and crc 0xFF)) +(bufset-u8 buf (+ p n 1) (bitwise-and (shr crc 8) 0xFF)) +} +) +(defun build-app-frame (buf reg n) (build-app-frame-from buf 0 0x20 reg n false)) +(defun app-cap-mah () cur-cap) +(defun app-bool-word () +(+ (if (= speedmode 2) 1 0) (if lock 2 0) (if (> alarm 0) 4 0) 2048) +) +(defun app-workmode () (cond ((= speedmode 2) 1) ((= speedmode 4) 2) (t 0))) +(defun app-refresh-7b () (if (!= model 1) (build-app-frame app-f-7b 0x7b 6))) +(defun app-todo+ (bit) (set 'app-todo (bitwise-or app-todo bit))) +(defun app-write (reg val) +(cond +((= reg 0x70) (if (and (!= val 0) (not lock) (<= (abs cur-speed-kmh) 0.5)) +(app-todo+ 32))) +((= reg 0x71) (if (and (!= val 0) lock (<= (abs cur-speed-kmh) 0.5)) +(app-todo+ 32))) +((= reg 0x75) (let ((m (cond ((= val 1) 2) ((= val 2) 4) (t 1)))) +(if (!= m speedmode) { +(set 'speedmode m) +(app-todo+ 16) +}))) +((= reg 0x7a) (let ((v (!= val 0))) +(if (not (eq v light)) { +(set 'light v) +(app-refresh-7b) +}))) +((= reg 0x7c) (let ((v (and (!= val 0) cruise-allow))) +(if (not (eq v cruise-enabled)) { +(set 'cruise-enabled v) +(app-refresh-7b) +(app-todo+ 1) +}))) +((= reg 0x7d) (let ((v (!= val 0))) +(if (not (eq v auto-taillight)) { +(set 'auto-taillight v) +(app-refresh-7b) +(app-todo+ 2) +}))) +((= reg 0x7b) (let ((m (cond ((= val 1) 1) ((= val 2) 4) (t 2)))) +(if (!= m speedmode) { +(set 'speedmode m) +(app-todo+ 16) +}))) +((= reg 0x77) (let ((u (!= val 0))) +(if (not (eq u unlock)) { +(set 'unlock u) +(app-todo+ 16) +}))) +((= reg 0x76) (let ((v (!= val 0))) +(if (not (eq v light)) { +(set 'light v) +(app-refresh-7b) +}))) +((= reg 0x90) (set 'light (!= val 0))) +((= reg 0x79) (if (!= val 0) (app-todo+ 64))) +((= reg 0x7e) (if (!= val 0) (set 'feedback 3))) +((or (= reg 0x91) (= reg 0x92)) (let ((v (!= val 0))) +(if (not (eq v alarm-tone)) { +(set 'alarm-tone v) +(app-todo+ 4) +}))) +((= reg 0x17) (if (and (>= val 0) (<= val 999999) (!= val app-pin)) { +(set 'app-pin val) +(app-build-pin) +(app-todo+ 8) +})) +) +) +(defun app-run-todo () +(if (!= app-todo 0) +(let ((td app-todo)) +{ +(set 'app-todo 0) +(if (= (bitwise-and td 64) 64) (ctrl-power false)) +(if (= (bitwise-and td 32) 32) (toggle-lock)) +(if (= (bitwise-and td 16) 16) (apply-mode)) +(if (= (bitwise-and td 1) 1) (write-setting 'cruise-enabled cruise-enabled)) +(if (= (bitwise-and td 2) 2) (write-setting 'auto-taillight auto-taillight)) +(if (= (bitwise-and td 4) 4) (write-setting 'alarm-tone alarm-tone)) +(if (= (bitwise-and td 8) 8) (write-setting 'app-pin app-pin)) +} +) +) +) +(defun nb-quick (reg) +(cond +((= reg 0xb0) (get-fault)) +((= reg 0xb1) (if (> alarm 0) 9 0)) +((= reg 0xb2) (app-bool-word)) +((= reg 0xb3) (+ (bitwise-and (rnd cur-batt) 0xFF) (shl (bitwise-and (rnd cur-batt) 0xFF) 8))) +((= reg 0xb4) (rnd cur-batt)) +((or (= reg 0xb5) (= reg 0xb6)) (app-speed-01)) +((= reg 0xb7) (bitwise-and cur-odo 0xFFFF)) +((= reg 0xb8) (shr cur-odo 16)) +((= reg 0xb9) (app-clamp16 (/ (app-trip-m) 10))) +((= reg 0xba) (to-i (secs-since app-boot-time))) +((= reg 0xbb) (app-fet-01)) +((= reg 0xbe) last-alarm-code) +((= reg 0xbc) (+ (bitwise-and (rnd cur-maxkmh) 0xFF) +(shl (bitwise-and (rnd cur-maxkmh) 0xFF) 8))) +((= reg 0xbd) (app-clamp16 (* cur-vin cur-amps))) +((= reg 0xbf) (app-range-10m)) +((and (>= reg 0xda) (<= reg 0xdf)) (app-word app-cpuid (- reg 0xda))) +(t 0) +) +) +(defun nb-word (reg) +(if (>= reg 0xb0) +(nb-quick reg) +(cond +((= reg 0x1a) app-ver) +((= reg 0x75) (app-workmode)) +((= reg 0x7b) (cond ((= speedmode 1) 1) ((= speedmode 4) 2) (t 0))) +((= reg 0x77) (if unlock 1 0)) +((= reg 0x76) (if light 1 0)) +((= reg 0x7c) (if (and cruise-allow cruise-enabled) 1 0)) +((= reg 0x7d) (if auto-taillight 2 0)) +((or (= reg 0x24) (= reg 0x25)) (app-range-10m)) +((= reg 0x3a) (to-i (secs-since app-boot-time))) +((and (>= reg 0x10) (< reg 0x17)) (app-word app-serial (- reg 0x10))) +((and (>= reg 0x17) (< reg 0x1a)) (app-word app-pin-buf (- reg 0x17))) +((= reg 0x1b) (get-fault)) +((= reg 0x1c) (if (> alarm 0) 9 0)) +((= reg 0x1d) (app-bool-word)) +((= reg 0x1f) (app-workmode)) +((= reg 0x22) (rnd cur-batt)) +((= reg 0x26) (app-speed-01)) +((= reg 0x29) (bitwise-and cur-odo 0xFFFF)) +((= reg 0x2a) (shr cur-odo 16)) +((= reg 0x2f) (app-clamp16 (/ (app-trip-m) 10))) +((= reg 0x32) (bitwise-and cur-runtime 0xFFFF)) +((= reg 0x33) (shr cur-runtime 16)) +((= reg 0x3e) (app-fet-01)) +((= reg 0x41) (app-clamp16 (* cur-mot 10))) +((= reg 0x46) 0x32) +((or (= reg 0x47) (= reg 0x48)) (app-volt-cv)) +((or (= reg 0x49) (= reg 0x53)) (app-amp-ca)) +((= reg 0x65) (app-speed-01)) +((or (= reg 0x66) (= reg 0x67) (= reg 0x68)) app-ver) +((= reg 0x72) (if (= speedmode 2) 1 0)) +((or (= reg 0x73) (= reg 0x74)) (app-clamp16 (* cur-maxkmh 10))) +((= reg 0x7a) (if light 1 0)) +((= reg 0x90) (if light 1 0)) +((or (= reg 0x91) (= reg 0x92)) (if alarm-tone 1 0)) +(t 0) +) +) +) +(defun nb-ack (from dst reg) +(let ((buf (array-create 10)) (crc (+ 1 from dst 0x05 reg 1))) +{ +(trap { +(bufset-u16 buf 0 0x5aa5) +(bufset-u8 buf 2 1) +(bufset-u8 buf 3 from) +(bufset-u8 buf 4 dst) +(bufset-u8 buf 5 0x05) +(bufset-u8 buf 6 reg) +(bufset-u8 buf 7 1) +(setq crc (bitwise-xor crc 0xFFFF)) +(bufset-u8 buf 8 (bitwise-and crc 0xFF)) +(bufset-u8 buf 9 (bitwise-and (shr crc 8) 0xFF)) +(uart-write buf) +}) +(free buf) +} +) +) +(defun app-hold (dev reg n) +{ +(if (or (< n 2) (> n 64)) (setq n 2)) +(setq n (bitwise-and n 0xFE)) +(if (not (and (>= n 20) levers-active)) +(if (not app-pend) { +(set 'pend-dev dev) (set 'pend-reg reg) (set 'pend-n n) +(set 'app-pend true) +} +(if (not app-pend2) { +(set 'pend2-dev dev) (set 'pend2-reg reg) (set 'pend2-n n) +(set 'app-pend2 true) +})) +) +} +) +(defun app-fr-len (n) (+ n (if (= model 1) 8 9))) +(defun xm-word (reg) +(if (or (= reg 0x26) (= reg 0x65) (= reg 0xb5) (= reg 0xb6)) +(app-clamp16 (* (abs cur-speed-kmh) 1000)) +(nb-word reg) +) +) +(defun xm-bms-word (reg) +(cond +((and (>= reg 0x10) (< reg 0x17)) (app-word app-serial (- reg 0x10))) +((= reg 0x17) app-ver) +((or (= reg 0x18) (= reg 0x19)) (app-cap-mah)) +((= reg 0x1a) 3600) +((= reg 0x20) 0x3021) +((= reg 0x30) (if (< cur-amps 0) 65 1)) +((= reg 0x31) (app-clamp16 (/ (* cur-batt cur-cap) 100))) +((= reg 0x32) (rnd cur-batt)) +((= reg 0x33) (app-amp-ca)) +((= reg 0x34) (app-volt-cv)) +((= reg 0x35) (let ((tc (bitwise-and (+ (rnd cur-fet) 20) 0xFF))) +(+ tc (shl tc 8)))) +((= reg 0x3b) cur-soh) +((and (>= reg 0x40) (< reg 0x4f) (< (- reg 0x40) cur-cells)) +(app-cell-mv (- reg 0x40))) +(t 0) +) +) +(defun app-frame (dev src cmd reg len) +(let ((xm (= model 1))) +(let ((p (if xm 3 4)) (n (if xm (- len 2) len))) +(if (= cmd 0x01) +(let ((want (if (> n 0) (bufget-u8 uart-buf p) 2))) +{ +(if (not xm) { +(if (!= src app-dst) (set 'app-dst src)) +(if (and (= reg 0xb0) (= want 52)) (set 'b0-wanted true)) +}) +(app-hold dev reg want) +} +) +{ +(if (and (> n 0) (!= dev 0x22)) +(app-write reg (if (> n 1) +(+ (bufget-u8 uart-buf p) (shl (bufget-u8 uart-buf (+ p 1)) 8)) +(bufget-u8 uart-buf p) +)) +) +(if (not xm) (nb-ack dev src reg)) +} +) +) +) +) +(defun g2-extras (len) +(if (>= len 4) +(let ((a (bufget-u8 uart-buf 7))) +{ +(if (= a 0x60) (set 'feedback 1)) +(if (and (= a 0x50) (!= g2-aux 0x50) cruise-allow (not off) (not lock)) { +(set 'cruise-enabled (not cruise-enabled)) +(set 'feedback 2) +(app-todo+ 1) +}) +(set 'g2-aux a) +} +) +) +) +(defunret read-frame () +{ +(uart-read-bytes uart-buf 3 0) +(loopwhile (!= (bufget-u16 uart-buf 0) rx-hdr) { +(bufset-u8 uart-buf 0 (bufget-u8 uart-buf 1)) +(bufset-u8 uart-buf 1 (bufget-u8 uart-buf 2)) +(uart-read-bytes uart-buf 1 2) +}) +(var len (bufget-u8 uart-buf 2)) +(if (> len 58) (return nil)) +(uart-read-bytes uart-buf (+ len rx-pre 2) 0) +(var crc len) +(looprange i 0 (+ len rx-pre) (setq crc (+ crc (bufget-u8 uart-buf i)))) +(if (= (+ (bufget-u8 uart-buf (+ len rx-pre)) +(shl (bufget-u8 uart-buf (+ len rx-pre 1)) 8)) +(bitwise-xor crc 0xFFFF)) +len +nil +) +} +) +(defun read-frames () +(loopwhile t { +(trap +(loopwhile t +(let ((len (read-frame))) +(if (not (eq len nil)) +(let ((cmd (bufget-u8 uart-buf (+ 1 rx-o))) +(dev (bufget-u8 uart-buf rx-o))) +(cond +((and (= cmd 0x61) (= rx-o 0)) { +(if (>= len 6) (adc-input 1)) +(if app-enable (app-hold dev (bufget-u8 uart-buf 2) +(bufget-u8 uart-buf 3))) +}) +((or (= cmd 0x61) (= cmd 0x65) (= cmd 0x64)) { +(if (>= (- len rx-sub) (if (and (= cmd 0x64) (= rx-o 1)) 7 3)) +(adc-input 0)) +(if (= model 3) (g2-extras len)) +(if (and (= cmd 0x64) +(> (secs-since dash-tx-time) dash-tx-iv)) { +(set 'dash-tx-time (systime)) +(if app-pend (send-dash-and-app) (update-dash)) +}) +}) +((and app-enable (>= cmd 0x01) (<= cmd 0x03) +(or (= rx-o 0) (= dev 0x20) (= dev 0x22))) +(trap (app-frame dev (bufget-u8 uart-buf 0) cmd +(bufget-u8 uart-buf (+ 2 rx-o)) len))) +) +) +) +) +) +) +(sleep 0.005) +}) +) +(defun combo-held(combo thr brk) +(cond +((= combo 0) (and (> brk adc-touch) (> thr adc-touch))) +((= combo 1) (and (> brk adc-touch) (<= thr adc-touch))) +((= combo 2) (and (> thr adc-touch) (<= brk adc-touch))) +((= combo 3) (and (<= brk adc-touch) (<= thr adc-touch))) +) +) +(defun combo-state-match(combo state) +(cond +((= combo 0) (= state 3)) +((= combo 1) (= state 1)) +((= combo 2) (= state 2)) +) +) +(defun toggle-secret() +{ +(set 'unlock (not unlock)) +(set 'feedback 2) +(apply-mode) +} +) +(defun toggle-lock() +{ +(set 'lock (not lock)) +(if (and lock secret-exit-on-lock) (set 'unlock false)) +(apply-mode) +(set 'feedback 1) +(if (not lock) +(stop-alarm) +) +} +) +(defun cycle-mode() +{ +(cond +((= speedmode 1) (set 'speedmode 4)) +((= speedmode 2) (set 'speedmode 1)) +((= speedmode 4) (set 'speedmode 2)) +) +(apply-mode) +} +) +(defun toggle-light() +(set 'light (not light)) +) +(def lock-safety-speed (/ 0.1 3.6)) +(defun gestures-allowed() (or off (<= (abs (get-speed)) button-safety-speed))) +(defun at-standstill() (or off (<= (abs (get-speed)) lock-safety-speed))) +(defun handle-button() +{ +(var thr lev-thr) +(var brk lev-brk) +(cond +((and off (= presses 1)) +{ +(set 'off false) +(set 'feedback 1) +(set 'unlock false) +(boot-state) +(apply-mode) +(stats-reset) +} +) +((and unlock +(if secret-off-requires-lock lock (not lock)) +(> secret-off-presses 0) +(= presses secret-off-presses) +(combo-held secret-off-combo thr brk)) +(toggle-secret) +) +((and secret-enabled +(> secret-presses 0) +(= presses secret-presses) +(combo-held secret-combo thr brk) +(or (not secret-requires-lock) lock)) +(toggle-secret) +) +((and (> lock-presses 0) +(= presses lock-presses) +(combo-held lock-combo thr brk)) +(if (at-standstill) (toggle-lock)) +) +((and (if mode-requires-lock lock (not lock)) +(> mode-presses 0) +(= presses mode-presses) +(combo-held mode-combo thr brk)) +(cycle-mode) +) +((and (if light-requires-lock lock (not lock)) +(> light-presses 0) +(= presses light-presses) +(combo-held light-combo thr brk)) +(toggle-light) +) +(lock (set 'feedback 1)) +) +} +) +(defun handle-lever-gestures() +{ +(var thr lev-thr) +(var brk lev-brk) +(var state (+ (if (> brk adc-touch) 1 0) (if (> thr adc-touch) 2 0))) +(if (!= state lever-state) { +(set 'lever-state state) +(set 'lever-since (systime)) +}) +(if (= state 0) (set 'lever-armed true)) +(if (and lever-armed (> state 0) (> (secs-since lever-since) 0.5)) +(cond +((and unlock (= secret-off-presses 0) +(if secret-off-requires-lock lock (not lock)) +(combo-state-match secret-off-combo state)) +{ +(set 'lever-armed false) +(toggle-secret) +} +) +((and secret-enabled +(= secret-presses 0) +(combo-state-match secret-combo state) +(or (not secret-requires-lock) lock)) +{ +(set 'lever-armed false) +(toggle-secret) +} +) +((and (if mode-requires-lock lock (not lock)) (= mode-presses 0) (combo-state-match mode-combo state)) +{ +(set 'lever-armed false) +(cycle-mode) +} +) +((and (if light-requires-lock lock (not lock)) (= light-presses 0) (combo-state-match light-combo state)) +{ +(set 'lever-armed false) +(toggle-light) +} +) +) +) +} +) +(defun handle-holding-button() +{ +(if (and (not lock) (not off)) +{ +(set 'light false) +(set 'feedback 1) +(set 'unlock false) +(apply-mode) +(set 'off true) +} +) +} +) +(defun reset-button() +{ +(set 'press-time (systime)) +(set 'presses 0) +} +) +(defun apply-mode() +(if (not unlock) +(cond +((= speedmode 1) (configure-speed drive-speed drive-watts drive-current drive-fw drive-om)) +((= speedmode 2) (configure-speed eco-speed eco-watts eco-current eco-fw eco-om)) +((= speedmode 4) (configure-speed sport-speed sport-watts sport-current sport-fw sport-om)) +) +(cond +((= speedmode 1) (configure-speed secret-drive-speed secret-drive-watts secret-drive-current secret-drive-fw secret-drive-om)) +((= speedmode 2) (configure-speed secret-eco-speed secret-eco-watts secret-eco-current secret-eco-fw secret-eco-om)) +((= speedmode 4) (configure-speed secret-sport-speed secret-sport-watts secret-sport-current secret-sport-fw secret-sport-om)) +) +) +) +(defun configure-speed(speed watts current fw om) +{ +(if apply-speed (set-param 'max-speed speed)) +(if apply-watts (set-param 'l-watt-max watts)) +(if apply-current (set-param 'l-current-max-scale current)) +(if apply-fw (set-param 'foc-fw-current-max fw)) +(if apply-om (set-param 'foc-overmod-factor om)) +} +) +(defun set-param(param value) +{ +(conf-set param value) +(loopforeach id (can-list-devs) +(looprange i 0 5 { +(if (eq (rcode-run id 0.1 `(conf-set (quote ,param) ,value)) t) (break t)) +false +}) +) +} +) +(defun start-alarm() +(if (= alarm 0) +{ +(set 'alarm 1) +(set 'last-alarm-code 9) +(set 'alarm-time (systime)) +(print "Alarm started") +} +) +) +(defun stop-alarm() +(if (> alarm 0) +{ +(set 'alarm 0) +(set-brake-rel 0) +(stop-tone) +(print "Alarm stopped") +} +) +) +(defun stop-current() +{ +(set-current-rel 0) +(loopforeach id (can-devs) +(rcode-run-noret id '(set-current-rel 0)) +) +} +) +(defun handle-lock(speed) +{ +(if lock +{ +(var gyro (get-gyro)) +(cond +((or (> (abs (ix gyro 0)) alarm-gyro-threshold) (> (abs (ix gyro 1)) alarm-gyro-threshold) (> (abs (ix gyro 2)) alarm-gyro-threshold)) +(start-alarm) +) +((> speed alarm-speed-threshold) +(start-alarm) +) +((> (secs-since alarm-time) 3) +(stop-alarm) +) +) +} +(stop-alarm) +) +(if lock +{ +(set-current-rel 0) +(if (and (> alarm 0) (> speed 0.0)) +(set-brake-rel 1) +(set-brake-rel 0) +) +} +) +(cond +((= alarm 2) +{ +(if alarm-tone +(play-tone 0 4000 alarm-voltage) +) +(set 'feedback 1) +} +) +((= alarm 3) +{ +(if alarm-tone +(play-tone 2 7000 alarm-voltage) +) +(set 'feedback 1) +} +) +((= alarm 6) +{ +(if alarm-tone +(play-tone 1 2000 alarm-voltage) +) +(set 'feedback 1) +} +) +((= alarm 8) +{ +(if alarm-tone +(stop-tone) +) +(set 'feedback 1) +(set 'alarm 1) +} +) +) +(if (> alarm 0) +(set 'alarm (+ alarm 1)) +) +} +) +(defun play-tone(channel freq voltage) +{ +(foc-play-tone channel freq voltage) +(loopforeach id (can-devs) +(rcode-run-noret id `(foc-play-tone ,channel ,freq ,voltage)) +) +} +) +(defun stop-tone() +{ +(foc-play-stop) +(loopforeach id (can-devs) +(rcode-run-noret id '(foc-play-stop)) +) +} +) +(defun can-devs () +{ +(if (> (secs-since can-ids-time) 0.5) { +(set 'can-ids-time (systime)) +(set 'can-ids (can-list-devs)) +}) +can-ids +} +) +(defun check-slip () +(if (<= lev-brk adc-touch) +(let ((r (abs (get-rpm)))) +(let ((lo r) (hi r)) +{ +(trap (loopforeach i (can-devs) +(let ((v (abs (canget-rpm i)))) +{ (if (< v lo) (setq lo v)) (if (> v hi) (setq hi v)) }))) +(if (and (> lo 0) (> (- hi lo) slip-erpm)) (set 'slip-time (systime))) +} +) +) +) +) +(defun wheel-slip () +(if (and (> slip-time 0) (< (secs-since slip-time) slip-hold)) 1 0)) +(defun get-lowest-speed() +{ +(var speed (get-speed)) +(trap +(loopforeach i (can-devs) +(let ((can-speed (canget-speed i))) +(if (< can-speed speed) (set 'speed can-speed)) +) +) +) +speed +} +) +(defun gyro-live (g) +(and (eq (type-of g) 'type-list) (= (length g) 3) +(or (> (abs (ix g 0)) 0) (> (abs (ix g 1)) 0) (> (abs (ix g 2)) 0))) +) +(defunret get-gyro() +{ +(var g (if (< gyro-src 0) (get-imu-gyro) (rcode-run gyro-src 0.5 '(get-imu-gyro)))) +(if (gyro-live g) (return g)) +(if (< (secs-since gyro-probe) 10.0) (return '(0.0 0.0 0.0))) +(set 'gyro-probe (systime)) +(set 'gyro-src -1) +(setq g (get-imu-gyro)) +(if (gyro-live g) (return g)) +(loopforeach i (can-devs) +(let ((c (rcode-run i 0.5 '(get-imu-gyro)))) +(if (gyro-live c) { +(set 'gyro-src i) +(return c) +}) +) +) +'(0.0 0.0 0.0) +} +) +(defun read-button-pin() +{ +(var sample-num 3) +(var sample-sum 0) +(looprange i 0 sample-num { +(sleep 0.02) +(setq sample-sum (+ sample-sum (gpio-read 'pin-rx))) +}) +(= (if (> sample-sum (/ sample-num 2)) 1 0) 0) +} +) +(defun button-logic() +{ +(loopwhile t +{ +(var button-state (read-button-pin)) +(if (and button-state (not last-button-state)) +{ +(set 'presses (+ presses 1)) +(set 'press-time (systime)) +} +) +(set 'last-button-state button-state) +(trap { +(set 'lev-thr (get-adc-decoded 0)) +(set 'lev-brk (get-adc-decoded 1)) +(button-apply button-state) +(if (and (not off) (gestures-allowed)) (handle-lever-gestures)) +(handle-features) +}) +} +) +} +) +(defun button-apply(button) +{ +(var time-passed (- (systime) press-time)) +(if (> time-passed 3000) +(if button +(if (> time-passed 6000) +{ +(if (at-standstill) +(handle-holding-button) +) +(reset-button) +} +) +(if (> presses 0) +{ +(if (gestures-allowed) +(handle-button) +) +(reset-button) +} +) +) +) +} +) +(defun main () { +(load-settings) +(event-register-handler (spawn event-handler)) +(event-enable 'event-data-rx) +(if (= model 2) { +(start-code-server) +} { +(boot-state) +(if rear-light-enable { +(pwm-start 200 0) +(set 'pwm-started true) +}) +(uart-start 115200 'half-duplex) +(gpio-configure 'pin-rx 'pin-mode-in-pu) +(def uart-buf (array-create 64)) +(def app-serial (array-create 14)) +(def app-cpuid (array-create 12)) +(def app-pin-buf (array-create 6)) +(def app-cells (array-create 30)) +(if (!= model 1) +{ +(def app-f-b0 (array-create 61)) +(def app-f-b4 (array-create 15)) +(def app-f-7b (array-create 15)) +(def app-f-1a (array-create 11)) +(def app-f-25 (array-create 11)) +(def app-f-3b (array-create 11)) +(def app-f-75 (array-create 11)) +(def app-f-da (array-create 21)) +(def app-f-33 (array-create 13)) +(def app-f-35 (array-create 11)) +(def app-f-31 (array-create 11)) +(def app-f-40 (array-create 39)) +} +) +(set 'cur-cells (let ((n (conf-get 'si-battery-cells))) (if (> n 0) n 10))) +(set 'cur-wh-tot (* 0.85 (conf-get 'si-battery-ah) (* 3.7 (conf-get 'si-battery-cells)))) +(set 'cur-cap (app-clamp16 (* (conf-get 'si-battery-ah) 1000))) +(set 'last-rx (systime)) +(set 'app-cache-time (systime)) +(set 'app-slow-time (systime)) +(set 'dash-tx-time 0) +(set 'press-time (systime)) +(set 'alarm-time (systime)) +(set 'lever-since (systime)) +(set 'blink-since (systime)) +(set 'cruise-since (systime)) +(set 'cruise-shown-time (systime)) +(set 'calib-since (systime)) +(app-build-serial) +(app-build-pin) +(if (!= model 1) { +(build-app-frame app-f-1a 0x1a 2) +(build-app-frame app-f-da 0xda 12) +(build-app-frame app-f-3b 0x3b 2) +(build-app-frame app-f-b0 0xb0 52) +}) +(set 'app-boot-time (systime)) +(if (= model 1) { +(define tx-frame (array-create 14)) +(bufset-u16 tx-frame 0 0x55AA) +(bufset-u16 tx-frame 2 0x0821) +(bufset-u16 tx-frame 4 0x6400) +(set 'rx-hdr 0x55aa) +(set 'rx-pre 1) +(set 'rx-o 0) +(set 'rx-sub 2) +(set 'tx-base 6) +(set 'thr-idx 4) +(set 'brk-idx 5) +} { +(define tx-frame (array-create (if (= model 3) 17 15))) +(bufset-u16 tx-frame 0 0x5AA5) +(bufset-u8 tx-frame 2 (if (= model 3) 0x08 0x06)) +(bufset-u16 tx-frame 3 0x2021) +(bufset-u16 tx-frame 5 0x6400) +(if (= model 3) { +(bufset-u8 tx-frame 13 0x04) +(bufset-u8 tx-frame 14 0x91) +}) +(set 'rx-hdr 0x5aa5) +(set 'rx-pre 4) +(set 'tx-base 7) +(set 'thr-idx 5) +(set 'brk-idx 6) +}) +(looprange i 2 tx-base (set 'tx-hdr-sum (+ tx-hdr-sum (bufget-u8 tx-frame i)))) +(if (!= model 1) +(let ((dl (buflen tx-frame))) +{ +(def combo11 (array-create (+ dl 11))) +(def combo13 (array-create (+ dl 13))) +(def combo15 (array-create (+ dl 15))) +(def combo21 (array-create (+ dl 21))) +(def combo39 (array-create (+ dl 39))) +(def combo61 (array-create (+ dl 61))) +} +) +) +(apply-software-adc) +(apply-dash-power) +(set 'cur-batt (* (get-batt) 100)) +(spawn 500 read-frames) +(apply-mode) +(button-logic) +}) +}) +@const-end +(def img-ok true) +(set 'img-ok (image-save)) +(if (not img-ok) (print "image-save failed - script too big, will not boot")) +(main) diff --git a/scooter_dashboard_support/ui.qml b/scooter_dashboard_support/ui.qml new file mode 100644 index 000000000..dd0dabcd1 --- /dev/null +++ b/scooter_dashboard_support/ui.qml @@ -0,0 +1,3921 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Controls.Material 2.15 +import QtQuick.Layouts 1.15 +import Vedder.vesc.commands 1.0 +import Vedder.vesc.utility 1.0 + +Item { + id: root + anchors.fill: parent + + property Commands mCommands: VescIf.commands() + // Theme. VESC Tool resolves its palette at start-up and emits nothing when the + // theme changes, so these are read when the page is built - switching theme + // needs the tab reopened, the same as the rest of the app. Dark keeps the exact + // colours this UI was drawn in; light borrows the app's own so the cards stop + // being dark slabs under light-mode text. The light values are set here rather + // than taken from the app's palette: its names describe the dark theme, so + // darkBackground is white in light mode and the deepest surfaces came out the + // lightest. Light works the other way round from dark - the panels are white + // and sit above a grey page rather than below it, with a hairline edge so the + // white still reads, and every control is a clear step down from them. Anything that carries + // meaning by its colour - the mode pills, LOCK, SECRET, CRUISE, the battery + // bar - is left alone, and so is the white text sitting on top of those. + readonly property bool darkUi: Utility.isDarkMode() + readonly property color cDeep: darkUi ? "#26262b" : "#ffffff" + readonly property color cWell: darkUi ? "#26262b" : "#d9dee4" + readonly property color cCard: darkUi ? "#2b2b31" : "#ffffff" + readonly property color cCtl: darkUi ? "#33333a" : "#e2e6eb" + readonly property color cCtlHi: darkUi ? "#3a3a44" : "#dce0e6" + readonly property color cEdge: darkUi ? "#43434c" : "#ccd2d9" + readonly property color cTrack: darkUi ? "#43434c" : "#e2e6eb" + readonly property color cInk: darkUi ? "#ffffff" : "#16181c" + readonly property color cDim: darkUi ? "#a0a0aa" : "#5a626b" + readonly property color cAccent: darkUi ? "#f29c65" : "#9a4d08" + readonly property color cAccentBg: darkUi ? "#613c26" : "#f2c79a" + readonly property color cAccentBg2:darkUi ? "#583827" : "#f6d9bb" + readonly property color cDialog: darkUi ? "#3a3a42" : "#ffffff" + readonly property color cOff: darkUi ? "#6e6e76" : "#9aa2ab" + readonly property color cDim2: darkUi ? "#8e8e98" : "#79818a" + readonly property color cWarnBg: darkUi ? "#3a1416" : "#fde8e6" + readonly property color cWarnInk: darkUi ? "#ff8a80" : "#a3201a" + readonly property color cWarnTint: darkUi ? "#8e3b3b" : "#e8a9a5" + + property int loadedModel: -1 + // Read straight off the controller with getDecodedAdc, so the pin voltages + // cost the script nothing - it is already near the const-heap ceiling. + property real adcV1: 0.0 + property real adcV2: 0.0 + property bool isSlave: modelBox.currentIndex === 2 + // Load is only complete once every settings message has arrived. Saving + // before that would write empty fields as 0 and wipe modes/general, so + // Save stays disabled and the UI keeps re-requesting until all are in. + property var settingsSeen: ({}) + property int settingsSeenCount: 0 + property bool settingsLoaded: false + property bool saving: false + // Edits the controller has not been told about yet. textEdited, toggled and + // activated only fire for a real touch, so the fields a settings message + // fills in on load never count as changes. + property bool cfgDirty: false + + function markDirty() { if (settingsLoaded) cfgDirty = true } + + // Anything that throws here runs inside Component.onCompleted and takes + // getSettings() down with it, leaving the UI on "Loading..." for good - so + // a page SwipeView has not built yet, or a child that is not an Item, has + // to be stepped over rather than trusted. + function watchEdits(item) { + if (!item || !item.children) return + for (var i = 0; i < item.children.length; i++) { + var c = item.children[i] + if (!c) continue + if (c.textEdited) c.textEdited.connect(root.markDirty) + else if (c.toggled) c.toggled.connect(root.markDirty) + else if (c.activated) c.activated.connect(root.markDirty) + watchEdits(c) + } + } + + property bool statePending: false + property double statePendingSince: 0 + readonly property var settingsMsgs: ["model", "general", "temps", "modes", + "secret", "apply", "gesture", "misc", "rear", "cruise", "alarm"] + + function markSettingsSeen(key) { + if (settingsMsgs.indexOf(key) < 0 || settingsSeen[key]) + return + settingsSeen[key] = true + settingsSeenCount += 1 + if (settingsSeenCount >= settingsMsgs.length) { + settingsLoaded = true + // whatever is on screen is now exactly what the controller holds + cfgDirty = false + } + } + + function resetSettingsLoad() { + settingsSeen = ({}) + settingsSeenCount = 0 + settingsLoaded = false + } + property real titleSize: Qt.application.font.pointSize > 0 ? Qt.application.font.pointSize + 3 : 14 + + // The figure cards are laid out from real glyph widths, not from titleSize - + // that is a point size, while an anchor margin is in pixels, and the two are + // only the same number on a desktop. Widest case for each slot, so the four + // cards hold a column whatever they happen to be showing. + FontMetrics { id: chipUnitFm; font.bold: true; font.pointSize: root.titleSize * 0.96 } + + // Digits and caps carry no descender, so they do not sit in the middle of a + // box that reserves room for one - centring the box leaves the ink off centre + // by an amount that depends entirely on the font the device happens to use. + // Measure it there rather than bake in a pixel measured somewhere else. + FontMetrics { id: inkFm; font.bold: true; font.pointSize: root.titleSize } + readonly property real inkOffset: { + var t = inkFm.tightBoundingRect("0") + if (!t || t.height <= 0) return 0 + return inkFm.height / 2 - (inkFm.ascent + t.y + t.height / 2) + } + function inkNudge(scale) { return Math.round(root.inkOffset * scale * 10) / 10 } + // The unit pane is as wide as the widest unit plus a little air. The + // reading is right-aligned against it, so nothing is centred and there is + // no per-card offset left to get wrong. + readonly property real chipUnitW: Math.round(chipUnitFm.advanceWidth("km") + + chipUnitFm.height * 0.35) + + // The four readouts under the battery bar. Every one of these already + // arrives in the state line, so choosing between them costs the script + // nothing. Caps are one letter each: the tile reserves a column the width + // of an M and the digits line up across all four from it. + readonly property var statOpts: [ + { key: "vin", name: "Voltage" }, + { key: "amps", name: "Battery Current" }, + { key: "mamps", name: "Motor Current" }, + { key: "fet", name: "ESC Temperature" }, + { key: "mot", name: "Motor Temperature" }, + { key: "bmst", name: "Battery Temperature" }, + { key: "batt", name: "Battery Percentage" }, + { key: "up", name: "Uptime" }, + { key: "trip", name: "Trip Distance" } + ] + // Offered whenever the BMS is switched on, not once it has answered - the + // reading only arrives while a pack is actually reporting, and waiting for + // it hides the choice from anyone setting the scooter up beforehand. The + // tile itself shows "-" until there is something to show. + readonly property var statOptsShown: statOpts.filter(function (o) { + return o.key !== "bmst" || bmsSoc.checked + }) + property var statSel: ["vin", "amps", "fet", "mot"] + + // Uptime carries its unit in the cap slot, so the letter beside the + // number says what the number counts - seconds, minutes, hours or days. + function fmtUptime(sec) { + if (sec < 60) return { v: String(Math.floor(sec)), u: "s" } + if (sec < 3600) return { v: String(Math.floor(sec / 60)), u: "m" } + if (sec < 86400) return { v: String(Math.floor(sec / 3600)), u: "h" } + return { v: String(Math.floor(sec / 86400)), u: "d" } + } + + function statTile(key) { + switch (key) { + case "amps": return { unit: "A", val: String(Math.round(stAmps)) } + case "mamps": return { unit: "A", val: String(Math.round(stMotAmps)) } + case "fet": return { icon: "temp", val: String(Math.round(toShownTemp(stFet))), + warn: stFet > tempLimitC(tempWarningFet, 999) } + case "mot": return { icon: "temp", val: String(Math.round(toShownTemp(stMot))), + warn: stMot > tempLimitC(tempWarningMotor, 999) } + case "bmst": return stBmsTemp > -99 + ? { icon: "temp", val: String(Math.round(toShownTemp(stBmsTemp))), + warn: stBmsTemp > tempLimitC(bmsTempHi, 50) + || stBmsTemp < tempLimitC(bmsTempLo, 0) } + : { icon: "temp", val: "-" } + case "batt": return { icon: "batt", val: String(Math.round(stBatt)) } + case "up": var u = fmtUptime(stUptime) + return { unit: u.u, val: u.v } + case "trip": return { icon: "dist", val: useMph.checked + ? String(Math.round(stTrip * 0.621371)) + : String(Math.round(stTrip)) } + default: return { unit: "V", val: String(Math.round(stVin)) } + } + } + + function setStat(slot, key) { + var n = statSel.slice() + n[slot] = key + statSel = n + if (uiPrefs) uiPrefs.statSlots = n.join(",") + } + + // Qt.labs.settings is not guaranteed to be deployed in every VESC Tool + // build, and a failed file-level import takes the whole page down with it. + // Built at run time instead: if neither module is there the tiles simply + // stop being remembered between sessions. + property var uiPrefs: null + + function initPrefs() { + var decl = ' Settings { category: "scooter_dashboard_support"; property string statSlots: "" }' + var mods = ["import Qt.labs.settings 1.0;", "import QtCore 6.5;"] + for (var i = 0; i < mods.length; i++) { + try { + uiPrefs = Qt.createQmlObject(mods[i] + decl, root, "uiPrefs") + break + } catch (e) { uiPrefs = null } + } + if (uiPrefs && uiPrefs.statSlots !== "") { + var v = String(uiPrefs.statSlots).split(",") + if (v.length === 4) { + for (var k = 0; k < 4; k++) + if (!statOpts.some(function (o) { return o.key === v[k] })) return + statSel = v + } + } + } + + // Live scooter state for the Control tab + property bool stOff: false + property bool stLock: false + property bool stLight: false + property bool stSecret: false + property int stMode: 4 + property real stBatt: 0 + property real stVin: 0 + property real stSpeed: 0 + property real stWatts: 0 + property real stWhkm: 0 + property real stRange: 0 + property real stAmps: 0 + property real stMax: 60 + property bool stCruise: false + property bool stCruiseEn: false + property bool stCruiseAllow: false + property bool stSecretAllow: false + property int stMotors: 1 + property int stFault: 0 + property int stSlip: 0 + property real stMotAmps: 0 + property real stBmsTemp: -100 + property real stUptime: 0 + property real stTrip: 0 + // above half, the fill reaches past the middle - so the centred figure + // and the range parked on the left are both sitting on colour + readonly property bool battOnFill: stBatt > 50 + property bool stImgOk: true + property real stFet: 0 + property real stMot: 0 + // The three colours everything coloured is drawn from, so the battery bar + // reads as part of the same set as the buttons rather than its own scheme. + readonly property color palRed: "#dc2e28" + readonly property color palAmber: "#e49e26" + readonly property color palGreen: "#25af32" + // text sitting on the charge fill: the fill is bright in either theme + readonly property color palOnFill: "#16181c" + + function mixColor(a, b, t) { + return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, 1) + } + + // The two warning lamps, traced off the real icons by tools/svg_to_canvas.py: + // closed rings in a unit box, the cut outs wound the other way so the plain + // fill leaves them open. A package carries no image files, and the modules + // that would take an SVG are not in VESC Tool's own QML, so Canvas draws them. + readonly property var iconEngine: [ + [0.2269, 0.1531, 0.6508, 0.1531, 0.6508, 0.204, 0.5356, 0.204, 0.5356, 0.2385, 0.6507, 0.2384, 0.6714, 0.2457, + 0.6817, 0.265, 0.6824, 0.3045, 0.7329, 0.3051, 0.7536, 0.318, 0.7853, 0.4285, 0.8151, 0.4285, 0.848, 0.3245, + 0.861, 0.3089, 0.9473, 0.3044, 0.9709, 0.3141, 0.9816, 0.343, 0.996, 0.4617, 0.9979, 0.6667, 0.9797, 0.8237, + 0.9673, 0.8413, 0.9414, 0.8469, 0.8747, 0.8465, 0.8563, 0.8386, 0.8157, 0.7232, 0.7859, 0.7226, 0.7564, 0.8281, + 0.7351, 0.8456, 0.3117, 0.8453, 0.214, 0.7226, 0.1199, 0.7221, 0.0968, 0.711, 0.0895, 0.688, 0.0895, 0.5449, + 0.0631, 0.5449, 0.0631, 0.6905, 0, 0.6905, 0, 0.3361, 0.0631, 0.3361, 0.0631, 0.4817, 0.0895, 0.4817, + 0.0894, 0.3393, 0.0982, 0.3139, 0.1192, 0.3046, 0.1953, 0.3045, 0.197, 0.2603, 0.217, 0.2398, 0.3555, 0.2385, + 0.3555, 0.204, 0.2269, 0.204], + [0.3245, 0.3016, 0.259, 0.3016, 0.2565, 0.347, 0.2368, 0.3661, 0.1532, 0.3677, 0.1532, 0.6589, 0.2481, 0.665, + 0.3425, 0.7829, 0.7028, 0.7828, 0.7345, 0.6755, 0.7474, 0.6627, 0.7681, 0.6591, 0.8414, 0.6595, 0.8601, 0.6672, + 0.9011, 0.7828, 0.9215, 0.7828, 0.9336, 0.6608, 0.9359, 0.5308, 0.9204, 0.3683, 0.9011, 0.3683, 0.8679, 0.4702, + 0.8562, 0.4864, 0.7601, 0.4915, 0.7336, 0.4754, 0.7028, 0.3677, 0.6484, 0.3671, 0.6298, 0.3599, 0.6192, 0.3408, + 0.6186, 0.3016]] + readonly property var iconSlip: [ + [0.6165, 0.0133, 0.7215, 0.0522, 0.8187, 0.1172, 0.899, 0.2057, 0.9507, 0.3002, 0.9836, 0.421, 0.9848, 0.5382, + 0.9619, 0.6443, 0.9099, 0.7513, 0.8347, 0.8418, 0.7234, 0.9201, 0.6693, 0.9435, 0.6474, 0.9387, 0.6371, 0.9207, + 0.6438, 0.9003, 0.7392, 0.8479, 0.8053, 0.7943, 0.8619, 0.7252, 0.9045, 0.6438, 0.9287, 0.5537, 0.9331, 0.4826, + 0.9273, 0.4122, 0.9026, 0.3246, 0.8579, 0.2411, 0.8044, 0.1779, 0.7387, 0.1247, 0.6628, 0.0853, 0.5929, 0.0634, + 0.4931, 0.0537, 0.4052, 0.0646, 0.324, 0.0913, 0.2128, 0.1624, 0.1642, 0.2134, 0.1262, 0.2674, 0.0797, 0.3802, + 0.0679, 0.4503, 0.0676, 0.5183, 0.0831, 0.6066, 0.1138, 0.6839, 0.1633, 0.7606, 0.2255, 0.8225, 0.2481, 0.8378, + 0.3132, 0.746, 0.3225, 0.7388, 0.3314, 0.743, 0.3897, 0.9593, 0.3737, 0.9711, 0.1672, 1, 0.1573, 0.9954, + 0.1564, 0.982, 0.2178, 0.8861, 0.1102, 0.781, 0.0684, 0.7147, 0.0384, 0.6462, 0.0184, 0.5666, 0.013, 0.484, + 0.019, 0.4046, 0.0387, 0.3266, 0.0701, 0.2565, 0.1117, 0.1917, 0.2123, 0.0932, 0.2888, 0.0477, 0.3607, 0.0193, + 0.4448, 0.0023, 0.5025, 0], + [0.5147, 0.1391, 0.8135, 0.6731, 0.8126, 0.6895, 0.7995, 0.7033, 0.6561, 0.7068, 0.1833, 0.706, 0.1676, 0.6874, + 0.1816, 0.6437, 0.4511, 0.1651, 0.4742, 0.1273, 0.4896, 0.1182], + [0.4644, 0.2842, 0.4548, 0.3073, 0.4679, 0.5415, 0.5124, 0.5398, 0.5229, 0.2979, 0.5056, 0.2793, 0.488, 0.275], + [0.4607, 0.5703, 0.4487, 0.5998, 0.4589, 0.6275, 0.4872, 0.6404, 0.516, 0.6305, 0.5298, 0.6, 0.5178, 0.5716, + 0.4888, 0.5592]] + + // The two units that read better as a picture than a letter. Drawn rather + // than set in text for the same reason the warning lamps are: a package + // ships no image files, and a font glyph for either one cannot be relied on. + // Both use the full box rather than its shortest side - the pane is taller + // than it is wide, and both shapes stand up. + function paintUnitIcon(ctx, kind, w, h, col) { + ctx.reset() + ctx.fillStyle = col + ctx.strokeStyle = col + ctx.lineJoin = "round" + ctx.lineCap = "round" + + // The canvas fills the pane, so centring here is exact - sizing the + // canvas instead left a rounded width that anchors could not halve + // evenly, and the glyph sat a pixel off. Work inset from it. + var iw = w * 0.58 + var ih = h * 0.48 + ctx.translate((w - iw) / 2, (h - ih) / 2) + w = iw + h = ih + + if (kind === "temp") { + // Traced off temperature-low (SVG Repo) on its own 24 grid: bulb of + // r4 at 8,17 and a 6-wide tube capped at 8,6. Stroke included that + // spans x 3.25..12.75 and y 2.25..21.75, which is what gets fitted. + var k = Math.min(w / 9.5, h / 19.5) + ctx.save() + ctx.translate((w - 9.5 * k) / 2 - 3.25 * k, (h - 19.5 * k) / 2 - 2.25 * k) + ctx.scale(k, k) + ctx.lineWidth = 1.5 + ctx.lineCap = "round" + ctx.lineJoin = "round" + + // body: round the bulb, up the left wall, over the cap, back down + ctx.beginPath() + ctx.arc(8, 17, 4, -0.7227, 3.8643) + ctx.lineTo(5, 6) + ctx.arc(8, 6, 3, Math.PI, 0) + ctx.lineTo(11, 14.354) + ctx.closePath() + ctx.stroke() + + // the column, and the bulb it stands in + ctx.beginPath() + ctx.moveTo(8, 16) + ctx.lineTo(8, 12) + ctx.stroke() + ctx.beginPath() + ctx.arc(8, 17, 1 + ctx.lineWidth / 2, 0, Math.PI * 2) + ctx.fill() + + ctx.restore() + return + } + + if (kind === "dist") { + // A pin beside the road it sits on, redrawn simply from location-on-road + // (SVG Repo): its winding ribbon and dashes turn to mush at this size, + // so the road is one stroke. Grid is 20 wide to match the battery. + var kd = Math.min(w / 20, h / 20) + ctx.save() + ctx.translate((w - 20 * kd) / 2, (h - 20 * kd) / 2) + ctx.scale(kd, kd) + ctx.lineWidth = 1.6 + + ctx.beginPath() + ctx.arc(6.6, 7.4, 4.4, Math.PI * 0.78, Math.PI * 0.22) + ctx.lineTo(6.6, 18.6) + ctx.closePath() + ctx.stroke() + + ctx.beginPath() + ctx.arc(6.6, 7.4, 1.7, 0, Math.PI * 2) + ctx.stroke() + + ctx.beginPath() + ctx.moveTo(6.6, 18.6) + ctx.bezierCurveTo(17.2, 20.4, 11.2, 11.4, 17.4, 4.6) + ctx.stroke() + + ctx.restore() + return + } + + // battery stood on end - the pane has height to spare and no width + var bw = w * 0.56 + var bh = h * 0.74 + var x0 = (w - bw) / 2 + var lw2 = Math.max(1.2, w * 0.10) + var r = w * 0.13 + ctx.lineWidth = lw2 + + // The terminal is part of the shape, so the body alone is not what gets + // centred - measure the pair, stroke included, and place from that. + var termH = h * 0.10 + var termGap = h * 0.012 + var top = (h - (termH + termGap + bh + lw2)) / 2 + var y0 = top + termH + termGap + lw2 / 2 + + ctx.beginPath() + ctx.rect(x0 + bw * 0.29, top, bw * 0.42, termH) + ctx.fill() + + ctx.beginPath() + ctx.moveTo(x0 + r, y0) + ctx.lineTo(x0 + bw - r, y0) + ctx.quadraticCurveTo(x0 + bw, y0, x0 + bw, y0 + r) + ctx.lineTo(x0 + bw, y0 + bh - r) + ctx.quadraticCurveTo(x0 + bw, y0 + bh, x0 + bw - r, y0 + bh) + ctx.lineTo(x0 + r, y0 + bh) + ctx.quadraticCurveTo(x0, y0 + bh, x0, y0 + bh - r) + ctx.lineTo(x0, y0 + r) + ctx.quadraticCurveTo(x0, y0, x0 + r, y0) + ctx.closePath() + ctx.stroke() + + // charge, filled from the foot up + var ix = x0 + lw2 * 1.5 + var iy = y0 + lw2 * 1.5 + var iw = bw - lw2 * 3 + var ih = bh - lw2 * 3 + ctx.beginPath() + ctx.rect(ix, iy + ih * 0.38, iw, ih * 0.62) + ctx.fill() + } + + // dx/dy are fractions of the badge: a shape is centred on its bounding box, + // which is not where the eye puts it once something sticks out of one corner. + // grow re-strokes the first ring in place - every stroke here is traced as a + // closed outline, so the only way to fatten one is to lay a line back down + // along its own edge, and only the ring that leads carries it. + function paintIcon(ctx, rings, size, frac, dx, dy, col, grow) { + ctx.reset() + var e = size * frac + var ox = (size - e) / 2 + dx * size + var oy = (size - e) / 2 + dy * size + ctx.beginPath() + for (var s = 0; s < rings.length; s++) { + var r = rings[s] + ctx.moveTo(ox + r[0] * e, oy + r[1] * e) + for (var i = 2; i < r.length; i += 2) + ctx.lineTo(ox + r[i] * e, oy + r[i + 1] * e) + ctx.closePath() + } + ctx.fillStyle = col + ctx.fill() + if (grow > 0) { + var f = rings[0] + ctx.beginPath() + ctx.moveTo(ox + f[0] * e, oy + f[1] * e) + for (var k = 2; k < f.length; k += 2) + ctx.lineTo(ox + f[k] * e, oy + f[k + 1] * e) + ctx.closePath() + ctx.lineWidth = e * grow + ctx.lineJoin = "round" + ctx.strokeStyle = col + ctx.stroke() + } + } + + // Highest draw seen this session, the fallback when nothing caps watts. + property real stWattPeak: 500 + // The sub-dial scales to the watt limit set for the mode that is running, so + // a given sweep means the same thing every session instead of rescaling + // itself the first time you pull hard. + readonly property real stWattMax: { + var on = applyWatts.checked + if (!on) + return stWattPeak + var f = stSecret ? (stMode === 2 ? secretEcoWatts : (stMode === 1 ? secretDriveWatts : secretSportWatts)) + : (stMode === 2 ? ecoWatts : (stMode === 1 ? driveWatts : sportWatts)) + var v = Number.parseFloat(f.text) + // the field is per motor; setup-current-in already combines every one + return v > 0 ? v * stMotors : stWattPeak + } + + function applyStateLine(line) { + var p = line.split(" ") + stOff = parseBoolToken(p[1]) + stLock = parseBoolToken(p[2]) + stLight = parseBoolToken(p[3]) + stSecret = parseBoolToken(p[4]) + stMode = Number.parseInt(p[5]) || 4 + stBatt = Number.parseFloat(p[6]) || 0 + stVin = Number.parseFloat(p[7]) || 0 + stSpeed = Number.parseFloat(p[8]) || 0 + stWatts = Number.parseFloat(p[9]) || 0 + stWhkm = Number.parseFloat(p[10]) || 0 + stRange = Number.parseFloat(p[11]) || 0 + stAmps = Number.parseFloat(p[12]) || 0 + stMax = Number.parseFloat(p[13]) || 60 + stCruise = parseBoolToken(p[14]) + stCruiseEn = parseBoolToken(p[15]) + stCruiseAllow = parseBoolToken(p[16]) + stImgOk = parseBoolToken(p[17]) + stFet = Number.parseFloat(p[18]) || 0 + stMot = Number.parseFloat(p[19]) || 0 + stSecretAllow = parseBoolToken(p[20]) + stMotors = Math.max(1, Number.parseInt(p[21]) || 1) + stFault = Number.parseInt(p[22]) || 0 + stSlip = Number.parseInt(p[23]) || 0 + stBmsTemp = Number.parseFloat(p[24]) + if (isNaN(stBmsTemp)) stBmsTemp = -100 + stUptime = Number.parseFloat(p[25]) || 0 + if (Math.abs(stWatts) > stWattPeak) + stWattPeak = Math.abs(stWatts) + } + + function ctrlCode(str) { + sendCode(str) + sendCode("(send-state)") + } + + function sendCode(str) { + mCommands.sendCustomAppData(str + "\0") + } + + // Saving fires a dozen commands and each one blocks the script for as long + // as its EEPROM writes take, so a fixed gap between them is a guess that a + // full group can outrun - and the dropped one is the last, which is what + // reloads the saved values into the running script. Wait for the script to + // answer instead. A step is idempotent, so a lost answer can be retried. + property var sendQueue: [] + property string sendCur: "" + property int sendTries: 0 + property bool sendBusy: false + + function queueCode(str) { + root.sendQueue.push(str) + if (!root.sendBusy) sendStep() + } + + function sendStep() { + if (root.sendCur === "") { + if (root.sendQueue.length === 0) { + root.sendBusy = false + ackTimer.stop() + return + } + root.sendCur = root.sendQueue.shift() + root.sendTries = 0 + } + root.sendBusy = true + root.sendTries += 1 + mCommands.sendCustomAppData(root.sendCur + "\0") + ackTimer.restart() + } + + function stepDone() { + if (!root.sendBusy) return + ackTimer.stop() + root.sendCur = "" + sendStep() + } + + // Long enough to clear the slowest step by a wide margin. finish-settings-save + // reloads, re-applies the mode - which can spend half a second per parameter + // on a CAN device that does not answer - and then sends all eleven groups + // back with a 50ms gap between them. Firing early there is worse than not + // firing at all: the retry re-sends every setting from EEPROM and overwrites + // whatever is on screen, which reads as a control that refuses to change. + Timer { + id: ackTimer + interval: 6000 + onTriggered: { + if (root.sendTries >= 2) root.sendCur = "" // give up, never stall + root.sendStep() + } + } + + // The script looks the names up itself, so a group travels as its name plus + // its values in the order scooter_support.lisp's setting-groups lists them. + function saveGroup(name, vals) { + queueCode("(save-group '" + name + " (list " + vals.join(" ") + "))") + } + + function boolAtom(box) { + return box.checked ? "true" : "false" + } + + function parseBoolToken(token) { + return token === "true" || token === "1" + } + + // Wh/km: "0" when zero, one decimal below 10, no decimals from 10 up + function fmtWhkm(v) { + if (v < 0.05) + return "0" + if (v >= 10) + return Math.round(v).toString() + return v.toFixed(1) + } + + // Lever combo codes: 0=brake+throttle, 1=brake only, 2=throttle only, 3=none + function comboFromBoxes(brakeBox, throttleBox) { + if (brakeBox.checked && throttleBox.checked) return 0 + if (brakeBox.checked) return 1 + if (throttleBox.checked) return 2 + return 3 + } + + function setBoxesFromCombo(combo, brakeBox, throttleBox) { + brakeBox.checked = (combo === 0 || combo === 1) + throttleBox.checked = (combo === 0 || combo === 2) + } + + // Boot mode dropdown index <-> speed mode value (2=eco, 1=drive, 4=sport) + function bootModeValue() { + return [2, 1, 4][bootMode.currentIndex] + } + + function setBootMode(value) { + bootMode.currentIndex = value === 2 ? 0 : (value === 1 ? 1 : 2) + } + + function pressesIndex(box, token, fallback) { + var n = Number.parseInt(token) + box.currentIndex = Number.isNaN(n) ? fallback : n + } + + function readReal(field, decimals) { + var number = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(number)) { + number = 0 + } + return number.toFixed(decimals) + } + + function setReal(field, value, decimals) { + var number = Number(value) + if (Number.isFinite(number)) { + field.text = (number % 1 === 0) ? number.toString() : number.toFixed(decimals) + } + } + + // Current is shown as a percentage in the UI but stored/sent as a 0-1 + // scale (VESC's l_current_max_scale). Convert at the UI boundary, and + // HARD-CAP at 100% - this is a multiplier of Motor Current Max, so a value + // above 100 would over-drive the motor. Never let the save path exceed 1.0. + function readPct(field) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) n = 0 + if (n < 0) n = 0 + if (n > 100) n = 100 + return (n / 100).toFixed(3) + } + + function setPct(field, value) { + var n = Number(value) * 100 + if (!Number.isFinite(n)) n = 0 + if (n < 0) n = 0 + if (n > 100) n = 100 + field.text = (Math.abs(n - Math.round(n)) < 0.05) ? Math.round(n).toString() : n.toFixed(1) + } + + // Clamp a current field to 0-100 as soon as the user leaves it (feedback) + function clampPct(field) { + setPct(field, readPct(field)) + } + + // Overmodulation: VESC's floor is 1.0 (no overmod). Never save below 1.0 - + // an empty field or a mistaken 0 would be an invalid/harmful factor. + function setOm(field, value) { + var n = Number(value) + if (Number.isFinite(n)) field.text = (n < 1.0 ? 1.0 : n).toFixed(3) + } + + function readOm(field) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n) || n < 1.0) n = 1.0 + return n.toFixed(3) + } + + function clampOm(field) { + setOm(field, readOm(field)) + } + + // Light compensation gain: never let a blank/garbage entry through as 0 + // (would divide by zero at runtime); floor/ceiling match the firmware clamp. + function readGain(field) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) n = 1.0 + if (n < 0.3) n = 0.3 + if (n > 3.0) n = 3.0 + return n.toFixed(3) + } + + function setGain(field, value) { + var n = Number(value) + if (!Number.isFinite(n)) n = 1.0 + if (n < 0.3) n = 0.3 + if (n > 3.0) n = 3.0 + field.text = n.toFixed(3) + } + + function clampGain(field) { + setGain(field, readGain(field)) + } + + function readOffsetV(field) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) n = 0.0 + if (n < -1.5) n = -1.5 + if (n > 1.5) n = 1.5 + return n.toFixed(3) + } + + function setOffsetV(field, value) { + var n = Number(value) + if (!Number.isFinite(n)) n = 0.0 + if (n < -1.5) n = -1.5 + if (n > 1.5) n = 1.5 + field.text = n.toFixed(3) + } + + function clampOffsetV(field) { + setOffsetV(field, readOffsetV(field)) + } + + // Calibration wizard state (light compensation sampling). One guided + // button per channel walks through two held lever positions (released, + // then full press). At each position the light is toggled off/on/off/on + // and sampled each time, so the light-off and light-on readings are a + // paired measurement from the identical lever position. + property string calibRunning: "" // "" / "thr" / "brk" - which channel is active + property string calibStage: "idle" // "idle" / "prep" / "settle" / "measure" / "release" + property string calibPhaseLabel: "rel" // "rel" / "full" - lever position being sampled + property string calibProgress: "" // "n/total" samples taken, shown while measuring + readonly property real calibPrepDuration: 3.0 // must match scooter_support.lisp calib-prep-duration + readonly property real calibReleaseDuration: 3.0 // must match calib-release-duration + property real calibRemaining: 0 + // a failed run leaves its reason on the button it was started from, for a + // few seconds - the status bar at the foot of the app is too easy to miss + property string calibError: "" + property string calibErrorText: "" + Timer { id: calibErrorTimer; interval: 7000; onTriggered: root.calibError = "" } + + function calibFail(ch, txt) { + root.calibRunning = "" + root.calibStage = "idle" + root.calibError = ch === "" ? "thr" : ch + root.calibErrorText = txt + calibErrorTimer.restart() + } + + // Shown on the button itself while that channel is running, replacing + // "Calibrate Throttle/Brake"; falls back to the button's normal label + function calibButtonText(ch) { + if (calibRunning === "" && calibError === ch) + return calibErrorText + if (calibRunning !== ch) + return ch === "thr" ? "Calibrate Throttle" : "Calibrate Brake" + var subject = ch === "thr" ? "throttle" : "brake" + if (calibStage === "release") return "Release " + subject + ", ending." + // the light toggles several times per position, each far too short for + // a useful countdown - show sample progress across the run instead + if (calibStage === "settle" || calibStage === "measure") + return "Measuring... " + calibProgress + // prep + var base = (calibPhaseLabel === "full") + ? "Press " + subject + " to maximum" + : "Keep " + subject + " released" + return base + " " + calibRemaining.toFixed(1) + "s" + } + + function calibStartChannel(ch) { + if (calibRunning !== "") return + calibError = "" + calibRunning = ch + calibStage = "prep" + calibPhaseLabel = "rel" + calibRemaining = calibPrepDuration + sendCode(ch === "thr" ? "(calib-start-thr)" : "(calib-start-brk)") + } + + // Speed fields are stored/sent in km/h but shown in mph when imperial units + // are on. Convert only at the UI boundary; km/h stays the internal unit. + readonly property real mphFactor: 0.621371 + + function readSpeed(field, decimals) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) n = 0 + if (useMph.checked) n = n / mphFactor // mph -> km/h + return n.toFixed(decimals) + } + + function setSpeed(field, kmh, decimals) { + var n = Number(kmh) + if (!Number.isFinite(n)) return + if (useMph.checked) n = n * mphFactor // km/h -> mph + field.text = (Math.abs(n - Math.round(n)) < 0.05) ? Math.round(n).toString() : n.toFixed(decimals) + } + + // Temperatures ride along with the same switch: the script keeps and stores + // celsius, the fields and the tiles show fahrenheit when it is on. + readonly property string degUnit: useMph.checked ? "°F" : "°C" + + function toShownTemp(c) { return useMph.checked ? (c * 9 / 5 + 32) : c } + + function readTemp(field, decimals) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) n = 0 + if (useMph.checked) n = (n - 32) * 5 / 9 // F -> C + return n.toFixed(decimals) + } + + function setTemp(field, c, decimals) { + var n = Number(c) + if (!Number.isFinite(n)) return + n = toShownTemp(n) + field.text = (Math.abs(n - Math.round(n)) < 0.05) ? Math.round(n).toString() : n.toFixed(decimals) + } + + // A warning threshold typed in fahrenheit still has to be compared against + // the celsius the script reports, so the tiles read the field back down. + function tempLimitC(field, fallback) { + var n = Number.parseFloat(field.text.replace(",", ".")) + if (!Number.isFinite(n)) return fallback + return useMph.checked ? (n - 32) * 5 / 9 : n + } + + // Re-render all speed and temperature fields when the unit toggle flips + function convertTempFields(toF) { + var fs = [tempWarningMotor, tempWarningFet, bmsTempHi, bmsTempLo] + for (var i = 0; i < fs.length; i++) { + var n = Number.parseFloat(fs[i].text.replace(",", ".")) + if (!Number.isFinite(n)) continue + var v = toF ? (n * 9 / 5 + 32) : ((n - 32) * 5 / 9) + fs[i].text = (Math.abs(v - Math.round(v)) < 0.05) ? Math.round(v).toString() : v.toFixed(1) + } + } + + function convertSpeedFields(toMiles) { + var factor = toMiles ? mphFactor : (1 / mphFactor) + var fs = [ecoSpeed, driveSpeed, sportSpeed, secretEcoSpeed, secretDriveSpeed, + secretSportSpeed, minSpeed, buttonSpeed, cruiseDeviation, cruiseMinSpeed, + cruiseMaxSpeed, alarmSpeedThreshold] + for (var i = 0; i < fs.length; i++) { + var n = Number.parseFloat(fs[i].text.replace(",", ".")) + if (Number.isFinite(n)) { + var v = n * factor + fs[i].text = (Math.abs(v - Math.round(v)) < 0.05) ? Math.round(v).toString() : v.toFixed(1) + } + } + } + + function saveAllSettings() { + if (!settingsLoaded || saving) + return + saving = true + saveTimeout.restart() + // One call per group, values in the order setting-groups lists them. + saveGroup("misc", [boolAtom(lightOnBoot), readSpeed(buttonSpeed, 1), boolAtom(useMph), + boolAtom(bmsSoc), boolAtom(secretExitOnLock), + readOffsetV(lightOffThr), readGain(lightGainThr), + readOffsetV(lightOffBrk), readGain(lightGainBrk), + (Number.parseInt(appPin.text) || 0), dashPowerOut.currentIndex, + boolAtom(headOffLock)]) + + saveGroup("general", [boolAtom(softwareAdc), boolAtom(softwareAdc2), + boolAtom(showBatteryInIdle), boolAtom(showBatterySecret), + readSpeed(minSpeed, 1), boolAtom(appEnable), idleDisplay.currentIndex]) + + saveGroup("temps", [readTemp(tempWarningMotor, 1), readTemp(tempWarningFet, 1), + readTemp(bmsTempHi, 1), readTemp(bmsTempLo, 1)]) + + saveGroup("modes", [ + readSpeed(ecoSpeed, 1), readPct(ecoCurrent), readReal(ecoWatts, 0), readReal(ecoFw, 1), + readSpeed(driveSpeed, 1), readPct(driveCurrent), readReal(driveWatts, 0), readReal(driveFw, 1), + readSpeed(sportSpeed, 1), readPct(sportCurrent), readReal(sportWatts, 0), readReal(sportFw, 1), + bootModeValue(), readOm(ecoOm), readOm(driveOm), readOm(sportOm)]) + + saveGroup("secret", [boolAtom(secretEnabled), + readSpeed(secretEcoSpeed, 1), readPct(secretEcoCurrent), readReal(secretEcoWatts, 0), readReal(secretEcoFw, 1), + readSpeed(secretDriveSpeed, 1), readPct(secretDriveCurrent), readReal(secretDriveWatts, 0), readReal(secretDriveFw, 1), + readSpeed(secretSportSpeed, 1), readPct(secretSportCurrent), readReal(secretSportWatts, 0), readReal(secretSportFw, 1), + readOm(secretEcoOm), readOm(secretDriveOm), readOm(secretSportOm)]) + + saveGroup("apply", [boolAtom(applySpeed), boolAtom(applyCurrent), boolAtom(applyWatts), + boolAtom(applyFw), boolAtom(applyOm)]) + + saveGroup("gesture", [ + secretPresses.currentIndex, comboFromBoxes(secretBrake, secretThrottle), boolAtom(secretRequiresLock), + (lockPresses.currentIndex + 1), comboFromBoxes(lockBrake, lockThrottle), + modePresses.currentIndex, comboFromBoxes(modeBrake, modeThrottle), boolAtom(modeLocked), + lightPresses.currentIndex, comboFromBoxes(lightBrake, lightThrottle), boolAtom(lightLocked), + secretOffPresses.currentIndex, comboFromBoxes(secretOffBrake, secretOffThrottle), + boolAtom(secretOffRequiresLock)]) + + saveGroup("rear", [boolAtom(rearLightEnable), boolAtom(autoTaillight), + [1, 2, 0][brakeLightMode.currentIndex], + ((tailBright.currentIndex + 1) / 10), boolAtom(tailOffLock)]) + + saveGroup("cruise", [boolAtom(cruiseEnable), readReal(cruiseDelay, 1), + readSpeed(cruiseDeviation, 1), readSpeed(cruiseMinSpeed, 1), readSpeed(cruiseMaxSpeed, 1)]) + + saveGroup("alarm", [boolAtom(alarmTone), readSpeed(alarmSpeedThreshold, 1), + readReal(alarmGyroThreshold, 1), readReal(alarmVoltage, 1)]) + + queueCode("(finish-settings-save)") + + // Model change needs a lisp restart, ack-gated via "model-ok" + if (modelBox.currentIndex !== loadedModel) { + queueCode("(save-model " + modelBox.currentIndex + ")") + } + } + + function getSettings() { + sendCode("(send-settings)") + } + + // Picking a model is the moment a controller's settings are defined - it may + // have run another package, or an older version of this one, and whatever + // that left behind is still in the slots. The script sends the defaults for + // every group, they land in the fields as an unsaved change, and the save + // writes them over the top. Slave drives nothing, so it is left alone. + function modelPicked(idx) { + cfgDirty = true + if (idx !== 2) sendCode("(send-defaults)") + } + + property var cfgLines: ({}) + + // The export is the very lines the script sends, so nothing can drift out of + // step with the parser. The model is left out: it is per unit, and applying it + // needs the restart that saving a model does. + // misc first, exactly as send-settings orders it: it carries the mph switch, and + // every speed after it is formatted for whichever unit it sets + readonly property var cfgKeys: ["misc", "general", "temps", "modes", "secret", + "apply", "gesture", "rear", "cruise", "alarm"] + + function cfgExport() { + var out = ["vss-settings 1"] + for (var i = 0; i < cfgKeys.length; i++) { + var l = cfgLines[cfgKeys[i]] + if (l !== undefined) out.push(l) + } + return out.join("\n") + } + + // returns the number of groups applied, or -1 if this is not one of ours + function cfgImport(txt) { + var lines = txt.split("\n") + var head = false + var found = ({}) + for (var i = 0; i < lines.length; i++) { + var l = lines[i].trim() + if (l.length === 0) continue + if (l.indexOf("vss-settings") === 0) { head = true; continue } + var key = l.split(" ")[0] + if (cfgKeys.indexOf(key) >= 0) found[key] = l + } + if (!head) return -1 + + // Apply in cfgKeys order whatever order the file is in, and hold + // settingsLoaded down: it is what gates useMph's field conversion, which + // would otherwise convert values that arrived already converted. + var wasLoaded = settingsLoaded + settingsLoaded = false + var n = 0 + // A backup from an older release is short a field or two per group. + // Nothing downstream notices: setReal ignores a missing number, but + // parseBoolToken reads undefined as false, so every toggle the old + // build did not have would import as off. The line the script last sent + // says how many fields a group takes now - anything else is refused. + var skipped = 0 + for (var k = 0; k < cfgKeys.length; k++) { + var line = found[cfgKeys[k]] + if (line === undefined) + continue + var known = cfgLines[cfgKeys[k]] + if (known !== undefined + && line.split(" ").length !== known.split(" ").length) { + skipped += 1 + continue + } + applySettingsLine(line) + n += 1 + } + settingsLoaded = wasLoaded + if (skipped > 0) + VescIf.emitStatusMessage( + "Skipped " + skipped + " group(s) from an older backup.", false) + return n + } + + function applySettingsLine(line) { + var parts = line.split(" ") + markSettingsSeen(parts[0]) + cfgLines[parts[0]] = line + + if (parts[0] === "model") { + loadedModel = Number.parseInt(parts[1]) + modelBox.currentIndex = loadedModel + } else if (parts[0] === "general") { + softwareAdc.checked = parseBoolToken(parts[1]) + softwareAdc2.checked = parseBoolToken(parts[2]) + showBatteryInIdle.checked = parseBoolToken(parts[3]) + showBatterySecret.checked = parseBoolToken(parts[4]) + setSpeed(minSpeed, parts[5], 1) + appEnable.checked = parseBoolToken(parts[6]) + idleDisplay.currentIndex = Number.parseInt(parts[7]) || 0 + } else if (parts[0] === "temps") { + setTemp(tempWarningMotor, parts[1], 1) + setTemp(tempWarningFet, parts[2], 1) + setTemp(bmsTempHi, parts[3], 1) + setTemp(bmsTempLo, parts[4], 1) + } else if (parts[0] === "modes") { + setSpeed(ecoSpeed, parts[1], 1) + setPct(ecoCurrent, parts[2]) + setReal(ecoWatts, parts[3], 0) + setReal(ecoFw, parts[4], 1) + setSpeed(driveSpeed, parts[5], 1) + setPct(driveCurrent, parts[6]) + setReal(driveWatts, parts[7], 0) + setReal(driveFw, parts[8], 1) + setSpeed(sportSpeed, parts[9], 1) + setPct(sportCurrent, parts[10]) + setReal(sportWatts, parts[11], 0) + setReal(sportFw, parts[12], 1) + setBootMode(Number.parseInt(parts[13]) || 4) + setOm(ecoOm, parts[14]) + setOm(driveOm, parts[15]) + setOm(sportOm, parts[16]) + } else if (parts[0] === "secret") { + secretEnabled.checked = parseBoolToken(parts[1]) + setSpeed(secretEcoSpeed, parts[2], 1) + setPct(secretEcoCurrent, parts[3]) + setReal(secretEcoWatts, parts[4], 0) + setReal(secretEcoFw, parts[5], 1) + setSpeed(secretDriveSpeed, parts[6], 1) + setPct(secretDriveCurrent, parts[7]) + setReal(secretDriveWatts, parts[8], 0) + setReal(secretDriveFw, parts[9], 1) + setSpeed(secretSportSpeed, parts[10], 1) + setPct(secretSportCurrent, parts[11]) + setReal(secretSportWatts, parts[12], 0) + setReal(secretSportFw, parts[13], 1) + setOm(secretEcoOm, parts[14]) + setOm(secretDriveOm, parts[15]) + setOm(secretSportOm, parts[16]) + } else if (parts[0] === "apply") { + applySpeed.checked = parseBoolToken(parts[1]) + applyCurrent.checked = parseBoolToken(parts[2]) + applyWatts.checked = parseBoolToken(parts[3]) + applyFw.checked = parseBoolToken(parts[4]) + applyOm.checked = parseBoolToken(parts[5]) + } else if (parts[0] === "gesture") { + pressesIndex(secretPresses, parts[1], 2) + setBoxesFromCombo(Number.parseInt(parts[2]) || 0, secretBrake, secretThrottle) + secretRequiresLock.checked = parseBoolToken(parts[3]) + var lp = Number.parseInt(parts[4]) + lockPresses.currentIndex = (Number.isNaN(lp) || lp < 1) ? 1 : lp - 1 + setBoxesFromCombo(Number.parseInt(parts[5]) || 0, lockBrake, lockThrottle) + pressesIndex(modePresses, parts[6], 2) + setBoxesFromCombo(Number.parseInt(parts[7]) || 0, modeBrake, modeThrottle) + modeLocked.checked = parseBoolToken(parts[8]) + pressesIndex(lightPresses, parts[9], 1) + setBoxesFromCombo(Number.parseInt(parts[10]) || 0, lightBrake, lightThrottle) + lightLocked.checked = parseBoolToken(parts[11]) + pressesIndex(secretOffPresses, parts[12], 3) + setBoxesFromCombo(Number.parseInt(parts[13]) || 0, secretOffBrake, secretOffThrottle) + secretOffRequiresLock.checked = parseBoolToken(parts[14]) + } else if (parts[0] === "misc") { + useMph.checked = parseBoolToken(parts[3]) // set unit before any speed field + lightOnBoot.checked = parseBoolToken(parts[1]) + setSpeed(buttonSpeed, parts[2], 1) + bmsSoc.checked = parseBoolToken(parts[4]) + secretExitOnLock.checked = parseBoolToken(parts[5]) + setOffsetV(lightOffThr, parts[6]) + setGain(lightGainThr, parts[7]) + setOffsetV(lightOffBrk, parts[8]) + setGain(lightGainBrk, parts[9]) + appPin.text = ("00000" + (Number.parseInt(parts[10]) || 0)).slice(-6) + dashPowerOut.currentIndex = Number.parseInt(parts[11]) || 0 + headOffLock.checked = parseBoolToken(parts[12]) + } else if (parts[0] === "rear") { + rearLightEnable.checked = parseBoolToken(parts[1]) + autoTaillight.checked = parseBoolToken(parts[2]) + var blm = Number.parseInt(parts[3]) + brakeLightMode.currentIndex = blm === 1 ? 0 : (blm === 2 ? 1 : 2) + tailBright.currentIndex = Math.max(0, Math.min(9, + Math.round((Number.parseFloat(parts[4]) || 0.4) * 10) - 1)) + tailOffLock.checked = parseBoolToken(parts[5]) + } else if (parts[0] === "cruise") { + cruiseEnable.checked = parseBoolToken(parts[1]) + setReal(cruiseDelay, parts[2], 1) + setSpeed(cruiseDeviation, parts[3], 1) + setSpeed(cruiseMinSpeed, parts[4], 1) + setSpeed(cruiseMaxSpeed, parts[5], 1) + } else if (parts[0] === "alarm") { + alarmTone.checked = parseBoolToken(parts[1]) + setSpeed(alarmSpeedThreshold, parts[2], 1) + setReal(alarmGyroThreshold, parts[3], 1) + setReal(alarmVoltage, parts[4], 1) + } + } + + Component.onCompleted: { + initPrefs() + // page 0 is the Control tab - its switches are written when they are + // flipped, so they are not what Save is for. Settings loading must not + // depend on this working, so it goes first and cannot take it down. + try { + for (var i = 1; i < swipeView.count; i++) + watchEdits(swipeView.itemAt(i)) + } catch (e) { + console.log("watchEdits: " + e) + } + getSettings() + } + + Timer { + id: restartTimer + interval: 400 + onTriggered: { + mCommands.lispSetRunning(true) + reloadTimer.start() + } + } + + Timer { + id: reloadTimer + interval: 1500 + onTriggered: getSettings() + } + + Timer { + interval: 150 + repeat: true + running: swipeView.currentIndex === 3 && (softwareAdc.checked || softwareAdc2.checked) + onTriggered: mCommands.getDecodedAdc() + } + + // Motor current and trip distance are the controller's own figures, so they + // are read from it rather than carried through the script. Setup values are + // combined across CAN, which is what setup-current-in gives the rest of the + // state line, and tachometer_abs is metres with the wheel size applied. + Timer { + interval: 300 + repeat: true + running: swipeView.currentIndex === 0 + onTriggered: mCommands.getValuesSetup() + } + + // Client-side countdown for the light-calibration hold. Purely visual - + // the actual result comes from calib-progress/calib-result messages, so a + // slightly early/late display here has no functional effect. + Timer { + interval: 200 + repeat: true + running: root.calibRunning !== "" + onTriggered: { + root.calibRemaining = Math.max(0, root.calibRemaining - 0.2) + } + } + + // Clears the "Saving..." state if the ack never comes back. Every setting + // written yields to the rest of the system, so a full save is a few seconds. + Timer { + id: saveTimeout + interval: 25000 + onTriggered: root.saving = false + } + + // mc_fault_code in firmware order. A code past the end of the table is shown + // as its number rather than guessed at, so a newer firmware still reads. + readonly property var faultNames: [ + "None", "Over Voltage", "Under Voltage", "DRV", "ABS Over Current", + "ESC Over Temperature", "Motor Over Temperature", + "Gate Driver Over Voltage", "Gate Driver Under Voltage", + "MCU Under Voltage", "Booting From Watchdog Reset", "Encoder SPI", + "Encoder Sin/Cos Below Min Amplitude", "Encoder Sin/Cos Above Max Amplitude", + "Flash Corruption", "High Offset Current Sensor 1", + "High Offset Current Sensor 2", "High Offset Current Sensor 3", + "Unbalanced Currents", "Brake", "Resolver LOT", "Resolver DOS", + "Resolver LOS", "Flash Corruption App CFG", "Flash Corruption MC CFG", + "Encoder No Magnet", "Encoder Magnet Too Strong", "Phase Filter", + "Encoder Fault", "LV Output Fault" + ] + + function faultName(code) { + return (code >= 0 && code < faultNames.length) ? faultNames[code] + : ("Fault " + code) + } + + Dialog { + id: faultInfo + background: Rectangle { radius: 16; color: root.cDialog } + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: Math.min(parent.width - 40, 360) + modal: true + + ColumnLayout { + width: faultInfo.availableWidth + spacing: 6 + + Label { + Layout.fillWidth: true + Layout.bottomMargin: 4 + text: "Controller Fault" + font.bold: true + font.pointSize: root.titleSize * 1.1 + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: faultText.implicitHeight + 28 + radius: 12 + color: root.cWarnBg + Label { + id: faultText + anchors.fill: parent + anchors.margins: 14 + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter + text: root.faultName(root.stFault) + " (" + root.stFault + ")" + font.bold: true + color: root.cWarnInk + } + } + + Label { + Layout.fillWidth: true + Layout.topMargin: 2 + wrapMode: Text.WordWrap + text: "The controller's own fault code. VESC Tool's terminal has the " + + "full history with the values at the moment it tripped." + font.pointSize: root.titleSize * 0.82 + color: root.cDim + } + + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 6 + Layout.preferredHeight: 44 + radius: 12 + color: faultClose.pressed ? root.cCtlHi : root.cCtl + Label { + anchors.centerIn: parent + text: "Close" + font.bold: true + color: root.cInk + } + MouseArea { + id: faultClose + anchors.fill: parent + onClicked: faultInfo.close() + } + } + } + } + + Dialog { + id: statPick + property int slot: 0 + function openAt(i) { slot = i; open() } + background: Rectangle { radius: 16; color: root.cDialog } + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: Math.min(parent.width - 40, 360) + modal: true + + ColumnLayout { + width: statPick.availableWidth + spacing: 6 + + Label { + Layout.fillWidth: true + Layout.bottomMargin: 4 + text: "Select content:" + horizontalAlignment: Text.AlignHCenter + font.bold: true + font.pointSize: root.titleSize * 1.1 + } + + Repeater { + model: root.statOptsShown + Rectangle { + id: optRow + Layout.fillWidth: true + Layout.preferredHeight: 44 + radius: 12 + property bool taken: root.statSel[statPick.slot] === modelData.key + color: taken ? root.cAccentBg2 : (optTouch.pressed ? root.cCtlHi : root.cCtl) + Label { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + verticalAlignment: Text.AlignVCenter + horizontalAlignment: Text.AlignHCenter + text: modelData.name + font.bold: true + color: optRow.taken ? root.cAccent : root.cInk + } + MouseArea { + id: optTouch + anchors.fill: parent + onClicked: { root.setStat(statPick.slot, modelData.key); statPick.close() } + } + } + } + } + } + + Dialog { + id: resetDialog + background: Rectangle { radius: 16; color: root.cDialog } + // centred from bindings that re-run when the size changes. anchors.centerIn + // settles once, while the content is still resolving its width, and the + // offset it lands on then sticks for every later open. + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: Math.min(parent.width - 40, 360) + modal: true + // No title and no standardButtons: either one makes Material build a header + // or footer with an opaque background of its own, drawn over the rounded + // one - which is what banded the top and bottom. The title and the buttons + // are part of the content instead, and match the rest of the UI. + + ColumnLayout { + width: resetDialog.availableWidth + spacing: 10 + + Label { + Layout.fillWidth: true + text: "Reset all settings?" + font.bold: true + font.pointSize: root.titleSize * 1.1 + } + + Label { + Layout.fillWidth: true + Layout.preferredWidth: 1 + wrapMode: Text.WordWrap + text: "This restores every setting to defaults, including the model, " + + "which goes back to Slave. The script restarts. This cannot be undone." + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + spacing: 8 + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: resetNo.pressed ? root.cCtlHi : root.cCtl + Label { anchors.centerIn: parent; text: "Cancel"; font.bold: true; color: root.cInk } + MouseArea { id: resetNo; anchors.fill: parent; onClicked: resetDialog.close() } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: resetYes.pressed ? "#d43a34" : "#c31f1f" + Label { anchors.centerIn: parent; text: "Reset"; font.bold: true; color: "#ffffff" } + MouseArea { id: resetYes; anchors.fill: parent; onClicked: { + resetDialog.close() + resetSettingsLoad() + sendCode("(restore-settings-ui)") + } } + } + } + } + } + + // Material's own item delegate draws a square highlight inside our rounded + // popup. ListView.isCurrentItem is what the popup binds to highlightedIndex, + // so one delegate serves every dropdown without knowing which one it is in. + Component { + id: comboItem + ItemDelegate { + id: comboCell + width: ListView.view ? ListView.view.width : implicitWidth + height: 34 + text: modelData + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + highlighted: ListView.isCurrentItem + background: Rectangle { + radius: 10 + color: comboCell.highlighted ? root.cEdge : "transparent" + } + } + } + + // VESC Tool's QML has no way to write a file on the phone or the desktop, so a + // backup travels as text through the clipboard - paste it wherever you keep it. + Dialog { + id: exportDialog + background: Rectangle { radius: 16; color: root.cDialog } + // centred from bindings that re-run when the size changes. anchors.centerIn + // settles once, while the content is still resolving its width, and the + // offset it lands on then sticks for every later open. + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: Math.min(parent.width - 40, 420) + modal: true + // No title and no standardButtons: either one makes Material build a header + // or footer with an opaque background of its own, drawn over the rounded + // one - which is what banded the top and bottom. The title and the buttons + // are part of the content instead, and match the rest of the UI. + + ColumnLayout { + width: exportDialog.availableWidth + spacing: 8 + + Label { + Layout.fillWidth: true + text: "Export settings" + font.bold: true + font.pointSize: root.titleSize * 1.1 + } + + Label { + Layout.fillWidth: true + Layout.preferredWidth: 1 + wrapMode: Text.WordWrap + text: "This is everything saved on the ESC except the model. Copy it and " + + "keep it somewhere - a note, a file, a message to yourself." + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 150 + radius: 10 + color: root.cWell + + // TextArea.flickable is what makes a text box scroll: attached that + // way the TextArea drives the Flickable's contentHeight and keeps the + // caret in view. Anchored inside a fixed height parent it just grew + // past it instead, with the caret walking off the bottom. + Flickable { + anchors.fill: parent + anchors.margins: 8 + clip: true + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + TextArea.flickable: TextArea { + id: exportText + readOnly: true + wrapMode: TextArea.Wrap + font.family: "monospace" + font.pointSize: root.titleSize * 0.72 + padding: 0 + background: Item {} + } + ScrollBar.vertical: ScrollBar { } + } + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 8 + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: closeTouch.pressed ? root.cCtlHi : root.cCtl + Label { anchors.centerIn: parent; text: "Close"; font.bold: true } + MouseArea { id: closeTouch; anchors.fill: parent; onClicked: exportDialog.close() } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 2 + Layout.preferredHeight: 44 + radius: 12 + color: copyTouch.pressed ? "#1f9c2e" : "#1b8728" + Label { anchors.centerIn: parent; text: "Copy"; font.bold: true; color: "#ffffff" } + MouseArea { + id: copyTouch + anchors.fill: parent + onClicked: { + exportText.selectAll() + exportText.copy() + exportText.deselect() + VescIf.emitStatusMessage("Settings copied to clipboard.", true) + } + } + } + } + } + } + + Dialog { + id: importDialog + background: Rectangle { radius: 16; color: root.cDialog } + // centred from bindings that re-run when the size changes. anchors.centerIn + // settles once, while the content is still resolving its width, and the + // offset it lands on then sticks for every later open. + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + width: Math.min(parent.width - 40, 420) + modal: true + // No title and no standardButtons: either one makes Material build a header + // or footer with an opaque background of its own, drawn over the rounded + // one - which is what banded the top and bottom. The title and the buttons + // are part of the content instead, and match the rest of the UI. + + ColumnLayout { + width: importDialog.availableWidth + spacing: 8 + + Label { + Layout.fillWidth: true + text: "Import settings" + font.bold: true + font.pointSize: root.titleSize * 1.1 + } + + Label { + Layout.fillWidth: true + Layout.preferredWidth: 1 + wrapMode: Text.WordWrap + text: "Paste a backup here. It only fills in the fields - press Save " + + "afterwards to write it to the ESC." + } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 150 + radius: 10 + color: root.cWell + + // TextArea.flickable is what makes a text box scroll: attached that + // way the TextArea drives the Flickable's contentHeight and keeps the + // caret in view. Anchored inside a fixed height parent it just grew + // past it instead, with the caret walking off the bottom. + Flickable { + anchors.fill: parent + anchors.margins: 8 + clip: true + flickableDirection: Flickable.VerticalFlick + boundsBehavior: Flickable.StopAtBounds + TextArea.flickable: TextArea { + id: importText + wrapMode: TextArea.Wrap + font.family: "monospace" + font.pointSize: root.titleSize * 0.72 + padding: 0 + background: Item {} + } + ScrollBar.vertical: ScrollBar { } + } + } + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 2 + spacing: 8 + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: cancelTouch.pressed ? root.cCtlHi : root.cCtl + Label { anchors.centerIn: parent; text: "Cancel"; font.bold: true } + MouseArea { id: cancelTouch; anchors.fill: parent; onClicked: importDialog.close() } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: pasteTouch.pressed ? root.cCtlHi : root.cCtl + Label { anchors.centerIn: parent; text: "Paste"; font.bold: true } + MouseArea { + id: pasteTouch + anchors.fill: parent + onClicked: { importText.selectAll(); importText.paste() } + } + } + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: applyTouch.pressed ? "#1f9c2e" : "#1b8728" + Label { anchors.centerIn: parent; text: "Apply"; font.bold: true; color: "#ffffff" } + MouseArea { + id: applyTouch + anchors.fill: parent + onClicked: { + var n = root.cfgImport(importText.text) + if (n < 0) { + VescIf.emitStatusMessage("That is not a settings backup.", false) + } else if (n === 0) { + VescIf.emitStatusMessage("Nothing in that backup could be read.", false) + } else { + importDialog.close() + VescIf.emitStatusMessage(n + " groups loaded - press Save to keep them.", true) + } + } + } + } + } + } + } + + // The script may still be booting (or writing first-install defaults) + // when the UI opens - keep asking until it answers + Timer { + interval: 1200 + repeat: true + running: !root.settingsLoaded + onTriggered: getSettings() + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 6 + spacing: 4 + + // No chrome on the Control screen - its gear lives in the dial. This + // header is the settings one: a way back, and a section picker. + Item { + visible: swipeView.currentIndex !== 0 + Layout.fillWidth: true + Layout.preferredHeight: 42 + + Rectangle { + id: backBtn + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: 40 + height: 38 + radius: 12 + color: backTouch.pressed ? root.cCtlHi : root.cWell + Behavior on color { ColorAnimation { duration: 140 } } + + Canvas { + anchors.fill: parent + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var cx = width / 2 + var cy = height / 2 + var d = height * 0.20 + ctx.strokeStyle = root.cInk + ctx.lineWidth = Math.max(2, height * 0.075) + ctx.lineCap = "round" + ctx.lineJoin = "round" + ctx.beginPath() + ctx.moveTo(cx + d * 0.6, cy - d) + ctx.lineTo(cx - d * 0.6, cy) + ctx.lineTo(cx + d * 0.6, cy + d) + ctx.stroke() + } + } + MouseArea { + id: backTouch + anchors.fill: parent + onClicked: swipeView.currentIndex = 0 + } + } + + Rectangle { + id: secTrack + anchors.left: backBtn.right + anchors.leftMargin: 8 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: 38 + radius: 12 + color: root.cWell + + Rectangle { + width: secTrack.width / 3 + height: parent.height + radius: parent.radius + x: (swipeView.currentIndex - 1) * secTrack.width / 3 + color: root.cEdge + Behavior on x { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } } + } + + Row { + anchors.fill: parent + Repeater { + model: ["General", "Modes", "Setup"] + Item { + width: secTrack.width / 3 + height: secTrack.height + Label { + anchors.centerIn: parent + text: modelData + font.bold: true + font.pointSize: root.titleSize * 0.95 + color: swipeView.currentIndex === index + 1 ? root.cInk : root.cDim2 + Behavior on color { ColorAnimation { duration: 200 } } + } + MouseArea { + anchors.fill: parent + onClicked: swipeView.currentIndex = index + 1 + } + } + } + } + } + } + + SwipeView { + id: swipeView + currentIndex: 0 + interactive: false + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + + Page { + // Not enabled: !isSlave - the gear that reaches Setup is on this + // page, and enabled is ANDed down the tree, so disabling the page + // would leave a slaved unit with no way into its own settings. + // The controls that command a scooter are gated one by one below. + + // Adaptive polling: keep at most one request in flight so it + // paces to the BLE round-trip instead of piling up (which caused + // periodic stalls). Re-sends after 700 ms if a reply is lost. + Timer { + interval: 40 + repeat: true + running: swipeView.currentIndex === 0 && !root.isSlave + onTriggered: { + var now = Date.now() + if (!root.statePending || (now - root.statePendingSince) > 700) { + root.statePending = true + root.statePendingSince = now + sendCode("(send-state)") + } + } + } + + ScrollView { + id: ctlScroll + anchors.fill: parent + contentWidth: availableWidth + contentHeight: ctlCol.implicitHeight + clip: true + // Both explicit: a ColumnLayout sized off parent.width leaves + // ScrollView deriving contentHeight from an item whose own parent + // is height driven BY contentHeight, and it latches short. Overshoot + // hid that, because a drag always did something. + // nothing to scroll means nothing moves - the flickable a + // ScrollView makes still rubber bands a page that already fits + Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds + + // The dial, the battery bar and the figure cards follow the + // width and nothing else. Only the buttons, the mode picker and + // the gaps give way to a short window, and only down to 0.69 - + // hS is solved so the page fills the height exactly until it + // hits that floor, and scrolls from there. + readonly property real wS: Math.max(0.85, Math.min(1.25, availableWidth / 320)) + // 96 is the height nothing scales: three 3px gaps between the three + // cards, 25 around the dial inside its own, the readings card's 2, and + // 20 of padding in it plus 40 in the buttons card, whose two rows each + // grew 10. 229 is what hS moves. 68 is the battery bar and the figure + // cards, which follow the width; 250 is everything hS does move. + readonly property real hS: Math.max(0.69, Math.min(1.0, + (height - availableWidth * 0.767 - 96 - 68 * wS) / 229)) + + ColumnLayout { + id: ctlCol + width: ctlScroll.availableWidth + spacing: 3 + + Rectangle { + visible: !root.stImgOk + Layout.fillWidth: true + Layout.topMargin: 10 + Layout.preferredHeight: imgWarnText.implicitHeight + 20 + radius: 12 + color: root.cWarnBg + border.color: "#ff5252" + border.width: 1 + Label { + id: imgWarnText + anchors.fill: parent + anchors.margins: 10 + text: "Script too big to save - it will not start after a reboot." + color: root.cWarnInk + font.bold: true + wrapMode: Text.WordWrap + verticalAlignment: Text.AlignVCenter + } + } + + // Speed dial. Drawn rather than assembled from segments so + // it sweeps smoothly, and the value it paints is animated + // so it glides between samples that arrive three times a + // second. The 240 degree sweep leaves the lower right open + // for the power sub-dial. + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: dial.height + 25 + radius: 18 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + + Item { + id: dial + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: 10 + // only as tall as the arc reaches - it stops at bottom + // left, so the circle's bottom quarter is dead space + height: dcy + drad * 0.7071 + dlw / 2 + + readonly property real dcx: width / 2 + readonly property real drad: width * 0.40 + readonly property real dlw: Math.max(12, drad * 0.21) + readonly property real dcy: drad + dlw / 2 + readonly property real srad: drad * 0.33 + readonly property real slw: Math.max(6, srad * 0.20) + readonly property real ccd: dlw // as wide as the ring is thick + readonly property real pad: width * 0.02 + // Both arcs stop at 135 and 330 degrees, so what the eye + // reads as their bottom and right is 0.7071 and 0.866 of the + // radius from centre, not the radius. Line the sub-dial up + // with those, or it sits short of both edges. + readonly property real subx: dcx + drad + dlw / 2 - srad * 0.866 - slw / 2 + readonly property real suby: dcy + drad * 0.7071 + dlw / 2 - srad * 0.7071 - slw / 2 + + readonly property real shown: useMph.checked ? (root.stSpeed * 0.621371) : root.stSpeed + readonly property real shownMax: Math.max(1, useMph.checked ? (root.stMax * 0.621371) : root.stMax) + + Canvas { + id: dialArc + anchors.fill: parent + + property real sFrac: Math.max(0, Math.min(1, dial.shown / dial.shownMax)) + property real wFrac: Math.max(0, Math.min(1, Math.abs(root.stWatts) / root.stWattMax)) + property real sAnim: 0 + property real wAnim: 0 + Behavior on sAnim { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + Behavior on wAnim { NumberAnimation { duration: 260; easing.type: Easing.OutCubic } } + onSFracChanged: sAnim = sFrac + onWFracChanged: wAnim = wFrac + onSAnimChanged: requestPaint() + onWAnimChanged: requestPaint() + Component.onCompleted: { sAnim = sFrac; wAnim = wFrac; requestPaint() } + + onPaint: { + var ctx = getContext("2d") + ctx.reset() + ctx.lineCap = "round" + + var cx = dial.dcx, cy = dial.dcy, r = dial.drad + var a0 = Math.PI * 0.75 // bottom left + var a1 = Math.PI * (330 / 180) // top right + + ctx.lineWidth = dial.dlw + ctx.strokeStyle = root.cCtl + ctx.beginPath() + ctx.arc(cx, cy, r, a0, a1, false) + ctx.stroke() + + if (sAnim > 0.004) { + var g = ctx.createLinearGradient(cx - r, cy + r, cx + r, cy - r) + g.addColorStop(0, "#f4ba2a") + g.addColorStop(1, "#ec6e1c") + ctx.strokeStyle = g + ctx.beginPath() + ctx.arc(cx, cy, r, a0, a0 + (a1 - a0) * sAnim, false) + ctx.stroke() + } + + var sr = dial.srad + var b0 = a0 + var b1 = a1 + + ctx.lineWidth = dial.slw + ctx.strokeStyle = root.cCtl + ctx.beginPath() + ctx.arc(dial.subx, dial.suby, sr, b0, b1, false) + ctx.stroke() + + if (wAnim > 0.004) { + ctx.strokeStyle = root.stWatts < 0 ? "#5edf64" : "#70cef8" + ctx.beginPath() + ctx.arc(dial.subx, dial.suby, sr, b0, b0 + (b1 - b0) * wAnim, false) + ctx.stroke() + } + } + } + + Label { + id: speedNum + anchors.horizontalCenter: parent.left + anchors.horizontalCenterOffset: dial.dcx + anchors.verticalCenter: parent.top + anchors.verticalCenterOffset: dial.dcy - dial.drad * 0.06 + text: Math.round(dial.shown) + font.pixelSize: dial.drad * 0.50 + font.bold: true + } + Label { + anchors.horizontalCenter: speedNum.horizontalCenter + anchors.top: speedNum.bottom + anchors.topMargin: -6 + text: useMph.checked ? "mph" : "km/h" + font.pixelSize: dial.drad * 0.13 + opacity: 0.55 + } + + Label { + id: wattNum + anchors.horizontalCenter: parent.left + anchors.horizontalCenterOffset: dial.subx + anchors.verticalCenter: parent.top + anchors.verticalCenterOffset: dial.suby + text: Math.round(root.stWatts) + font.pixelSize: dial.srad * 0.55 + font.bold: true + } + Label { + anchors.horizontalCenter: wattNum.horizontalCenter + anchors.top: wattNum.bottom + anchors.topMargin: -4 + text: "W" + font.pixelSize: dial.srad * 0.28 + opacity: 0.5 + } + + // Power and cruise sit in the corners the arc leaves + // empty, level with the top of the dial + Rectangle { + id: powerBtn + enabled: !root.isSlave + opacity: root.isSlave ? 0.3 : 1.0 + // whole pixels - a canvas on a fractional bound rounds its + // render target and the glyph drifts off the circle it sits in + x: Math.round(dial.width - width - dial.pad - 10) + y: Math.round(dial.dcy - dial.drad - dial.dlw / 2 + dial.pad) + width: Math.round(dial.width * 0.105) + height: width + radius: width / 2 + color: powerTouch.pressed ? root.cEdge : root.cCtl + scale: powerTouch.pressed ? 0.94 : 1.0 + Behavior on color { ColorAnimation { duration: 180 } } + Behavior on scale { NumberAnimation { duration: 90 } } + + Canvas { + // fills the badge and works from its own centre - + // centerIn on a half-width canvas left the glyph off + // by a rounded pixel, and the stem made it sit high, + // so the ring drops by half of what the stem sticks out + anchors.fill: parent + // the badge is neutral now, so the mark carries the state + property color ink: root.stOff ? root.cOff : root.cInk + onInkChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + var c = width / 2 + // 0.237 + half the stroke reaches 0.2875 either + // side, the cog's own extent, so the two badges + // carry a glyph of the same width + var r = width * 0.237 + var cy = c + r * 0.11 + ctx.strokeStyle = ink + ctx.lineWidth = Math.max(2, width * 0.100) + ctx.lineCap = "round" + ctx.beginPath() + ctx.arc(c, cy, r, Math.PI * -0.28, Math.PI * 1.28, false) + ctx.stroke() + ctx.beginPath() + ctx.moveTo(c, cy - r * 1.22) + ctx.lineTo(c, cy - r * 0.10) + ctx.stroke() + } + } + MouseArea { + id: powerTouch + anchors.fill: parent + anchors.margins: -12 // finger sized target under a small badge + onClicked: ctrlCode("(ctrl-power " + (root.stOff ? "true" : "false") + ")") + } + } + + Rectangle { + width: Math.round(dial.width * 0.105) + height: width + radius: width / 2 + x: Math.round(dial.pad + 10) + y: Math.round(dial.dcy - dial.drad - dial.dlw / 2 + dial.pad) + color: gearTouch2.pressed ? root.cEdge : root.cCtl + Behavior on color { ColorAnimation { duration: 140 } } + + Canvas { + anchors.fill: parent + property color hub: gearTouch2.pressed ? root.cEdge : root.cCtl + onHubChanged: requestPaint() + onPaint: { + var ctx = getContext("2d") + ctx.reset() + // seven teeth put no tooth due west, so the path + // reaches 0.0042 further east than west - take that + // back off so the drawn shape is what gets centred + var c = width / 2 - width * 0.0042 + var cy = width / 2 + // A solid cog: seven trapezoid teeth walked as + // alternating arcs at the tip and root radius, the + // corners rounded by stroking the same path, then the + // hub punched back out in the badge's own colour. + // Its 0.288 extent is the power mark's, so the badge + // shows the same ring of colour around both. + var pitch = Math.PI * 2 / 7 + var half = pitch * 0.22 + ctx.beginPath() + for (var i = 0; i < 7; i++) { + var a = i * pitch + ctx.arc(c, cy, width * 0.269, a - half, a + half, false) + ctx.arc(c, cy, width * 0.170, a + half * 1.35, + a + pitch - half * 1.35, false) + } + ctx.closePath() + ctx.fillStyle = root.cInk + ctx.fill() + ctx.lineJoin = "round" + ctx.lineWidth = width * 0.037 + ctx.strokeStyle = root.cInk + ctx.stroke() + ctx.beginPath() + ctx.arc(c, cy, width * 0.104, 0, Math.PI * 2) + ctx.fillStyle = hub + ctx.fill() + } + } + MouseArea { + id: gearTouch2 + anchors.fill: parent + anchors.margins: -6 + onClicked: swipeView.currentIndex = 1 + } + } + + // Consumption at the bottom centre, where the arc's opening + // leaves the space free, sat down on the level the ring's own + // ends reach. Figure in the speed's colour, unit in the same + // grey as km/h, so it reads as one more dial reading. + FontMetrics { id: whkmFm; font: whkmNum.font } + + Row { + anchors.horizontalCenter: parent.left + anchors.horizontalCenterOffset: dial.dcx + anchors.bottom: parent.bottom + // a label's box carries the font's descent below the + // baseline, so bottom alignment alone would leave the + // digits floating - drop it by exactly that much and + // they sit on the level both rings end at + anchors.bottomMargin: -Math.round(whkmFm.height - whkmFm.ascent) + spacing: 3 + + Label { + id: whkmNum + text: fmtWhkm(useMph.checked ? (root.stWhkm / 0.621371) : root.stWhkm) + font.bold: true + font.pixelSize: dial.drad * 0.13 + } + Label { + anchors.baseline: whkmNum.baseline + text: useMph.checked ? "Wh/mi" : "Wh/km" + font.pixelSize: dial.drad * 0.10 + opacity: 0.55 + } + } + + // Warning lamps, centred above the speed: a fault from the + // controller, and wheel slip. Slip is the spread between the + // fastest and slowest wheel, so a single motor never shows it. + // Both are the size of the CC badge, and both are only there + // when they have something to say - the row recentres on + // whichever is left. + Row { + anchors.horizontalCenter: parent.left + anchors.horizontalCenterOffset: dial.dcx + anchors.verticalCenter: parent.top + anchors.verticalCenterOffset: dial.dcy - dial.drad * 0.56 + spacing: Math.round(dial.ccd * 0.30) + + // Filled like the CC badge, icon knocked out dark: a + // thin coloured glyph washes out in sun, and amber on + // the light theme's white was barely there at all. What + // carries outdoors is a solid disc of colour. + Rectangle { + width: dial.ccd + height: width + radius: width / 2 + color: "#dc2e28" + visible: root.stFault > 0 + Canvas { + anchors.fill: parent + // a canvas hidden before it ever drew comes back blank + onVisibleChanged: if (visible) requestPaint() + onPaint: root.paintIcon(getContext("2d"), + root.iconEngine, width, + 0.64, 0, 0, root.palOnFill, 0.03) + } + // the badge is smaller than a fingertip, so the + // tap that names the fault reaches past its edge + MouseArea { + anchors.fill: parent + anchors.margins: -10 + onClicked: faultInfo.open() + } + } + + Rectangle { + id: tcLamp + width: dial.ccd + height: width + radius: width / 2 + readonly property bool slipping: root.stSlip > 0 + visible: slipping + // The blink inverts instead of fading: filled disc + // with the icon knocked out, then the icon alone in + // the disc's own amber. Nothing ever goes + // translucent, so neither half of the cycle washes + // out against the light theme's white. + property bool blinkOn: true + color: blinkOn ? "#e49e26" : "transparent" + onBlinkOnChanged: tcIcon.requestPaint() + onSlippingChanged: if (slipping) blinkOn = true + Timer { + interval: 240 + repeat: true + running: tcLamp.slipping + onTriggered: tcLamp.blinkOn = !tcLamp.blinkOn + } + Canvas { + id: tcIcon + anchors.fill: parent + onVisibleChanged: if (visible) requestPaint() + onPaint: root.paintIcon(getContext("2d"), + root.iconSlip, width, + 0.58, 0.015, 0.014, + tcLamp.blinkOn ? root.palOnFill + : "#e49e26", 0.05) + } + } + } + + // cruise sits inside the ring, on the right, flush with + // the dial's own edge + Rectangle { + width: dial.ccd + height: width + radius: width / 2 + x: dial.dcx + dial.drad + dial.dlw / 2 - width + y: dial.dcy - dial.drad * 0.05 - width / 2 + color: root.stCruise ? "#17aabc" : root.cCtl + Behavior on color { ColorAnimation { duration: 200 } } + Label { + anchors.centerIn: parent + text: "CC" + font.bold: true + font.pixelSize: dial.ccd * 0.42 + color: root.stCruise ? "#12262a" : root.cOff + Behavior on color { ColorAnimation { duration: 200 } } + } + } + } + } + + // Charge and the four readings are one card: they are one glance. + Rectangle { + Layout.fillWidth: true + Layout.topMargin: Math.round(4 * ctlScroll.hS) + 2 + Layout.preferredHeight: ctlReadCol.implicitHeight + 20 + radius: 18 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + + ColumnLayout { + id: ctlReadCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + spacing: 0 + + Item { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(28 * ctlScroll.wS) + + Rectangle { + id: battTrack + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + height: Math.round(27 * ctlScroll.wS) + radius: height / 2 + color: root.cCtl + + Rectangle { + height: parent.height + radius: parent.radius + width: Math.max(height, battTrack.width * Math.max(0, Math.min(1, root.stBatt / 100))) + // full green down to 60%, amber through the + // thirties, red only when the pack is really low - + // the same three colours the buttons are drawn from + color: { + var t = Math.max(0, Math.min(1, (root.stBatt - 8) / 52)) + return t < 0.5 ? root.mixColor(root.palRed, root.palAmber, t * 2) + : root.mixColor(root.palAmber, root.palGreen, (t - 0.5) * 2) + } + Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 320 } } + } + + Label { + anchors.centerIn: parent + text: Math.round(root.stBatt) + " %" + font.bold: true + font.pointSize: root.titleSize * 1.18 + color: root.battOnFill ? root.palOnFill : root.cInk + } + + // Range keeps to whichever end of the bar is one + // solid colour, so it never straddles the fill edge: + // the filled left while there is charge to spare, + // the empty right once the fill has retreated past + // the middle. The percentage holds the centre. + Label { + anchors.verticalCenter: parent.verticalCenter + // Positioned rather than re-anchored: binding an + // anchor to undefined is how you break one, and + // driving both ends that way leaves the label stuck + // at whichever end it was on when the fill crossed. + x: root.battOnFill ? 12 : (parent.width - width - 12) + text: "~" + Math.round(useMph.checked ? (root.stRange * 0.621371) + : root.stRange) + + (useMph.checked ? " mi" : " km") + font.bold: true + font.pointSize: root.titleSize * 1.18 + color: root.battOnFill ? root.palOnFill : root.cInk + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: Math.round(12 * ctlScroll.hS) + spacing: 8 + + Repeater { + model: root.statSel + Rectangle { + readonly property var tile: root.statTile(modelData) + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: Math.round(34 * ctlScroll.wS) + radius: 12 + color: root.cCtl + + clip: true + + // Two panes: the reading right-aligned against a + // slightly offset unit pane. The warning tint is laid + // over both, and the three labels over that, so the + // pane never cuts the blink in half. + Rectangle { + id: chipUnit + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + width: root.chipUnitW + radius: parent.radius + color: root.cCtlHi + // only the card's own right corners are round - + // square off the edge that meets the reading + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + width: parent.radius + color: parent.color + } + } + + Rectangle { + anchors.fill: parent + radius: parent.radius + color: root.cWarnTint + // undefined is not assignable to bool, + // and the property falls back to true + visible: tile.warn === true + SequentialAnimation on opacity { + running: true + loops: Animation.Infinite + NumberAnimation { from: 0; to: 0.85; duration: 520; easing.type: Easing.InOutQuad } + NumberAnimation { from: 0.85; to: 0; duration: 520; easing.type: Easing.InOutQuad } + } + } + + Label { + anchors.right: chipUnit.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenterOffset: root.inkNudge(1.0) + text: tile.val + font.bold: true + font.pointSize: root.titleSize * 1.0 + } + + Label { + anchors.centerIn: chipUnit + anchors.verticalCenterOffset: root.inkNudge(0.96) + visible: tile.icon === undefined + text: tile.unit ? tile.unit : "" + font.bold: true + font.pointSize: root.titleSize * 0.96 + opacity: 0.6 + } + + Canvas { + anchors.fill: chipUnit + visible: tile.icon !== undefined + opacity: 0.6 + // a canvas hidden before it ever drew comes back blank + onVisibleChanged: if (visible) requestPaint() + // the delegates outlive a value change now, + // so nothing repaints them after layout - the + // first paint can land before there is a size + onWidthChanged: requestPaint() + onHeightChanged: requestPaint() + Component.onCompleted: requestPaint() + onPaint: root.paintUnitIcon(getContext("2d"), + tile.icon, + width, height, root.cInk) + } + + // Last child, so it is above the reading: a press on + // the digits is a press on the tile, not a miss. + MouseArea { + id: chipTouch + anchors.fill: parent + property real pX: 0 + property real pY: 0 + // The scroll view steals the grab on the + // smallest drift, which cancels a hold - so + // hold the grab until the finger has actually + // travelled, then hand it back so the page + // still scrolls from here. + onPressed: { + pX = mouse.x; pY = mouse.y + preventStealing = true + chipHold.restart() + } + onPositionChanged: { + if (Math.abs(mouse.x - pX) > 14 + || Math.abs(mouse.y - pY) > 14) { + preventStealing = false + chipHold.stop() + } + } + onReleased: { preventStealing = false; chipHold.stop() } + onCanceled: { preventStealing = false; chipHold.stop() } + Timer { + id: chipHold + interval: 500 + onTriggered: { + chipTouch.preventStealing = false + statPick.openAt(index) + } + } + } + } + } + } + } + } + + // The mode picker and the four controls share a card, as on every other screen. + Rectangle { + Layout.fillWidth: true + Layout.topMargin: Math.round(7 * ctlScroll.hS) + Layout.preferredHeight: ctlBtnCol.implicitHeight + 20 + radius: 18 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + + ColumnLayout { + id: ctlBtnCol + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 10 + spacing: 0 + + // Segmented mode picker - the highlight slides to the + // active mode instead of three buttons lighting up + Item { + Layout.fillWidth: true + Layout.preferredHeight: Math.round(48 * ctlScroll.hS) + + Rectangle { + id: modeTrack + anchors.fill: parent + enabled: !root.isSlave + opacity: root.isSlave ? 0.3 : 1.0 + radius: 14 + color: root.cCtl + + Rectangle { + width: modeTrack.width / 3 + height: parent.height + radius: parent.radius + x: root.stMode === 2 ? 0 : (root.stMode === 1 ? modeTrack.width / 3 : modeTrack.width * 2 / 3) + color: root.stMode === 2 ? "#25af32" : (root.stMode === 1 ? "#1e80d5" : "#dc2e28") + Behavior on x { NumberAnimation { duration: 220; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 220 } } + } + + Row { + anchors.fill: parent + Repeater { + model: [ + { t: "ECO", m: 2 }, + { t: "DRIVE", m: 1 }, + { t: "SPORT", m: 4 } + ] + Item { + width: modeTrack.width / 3 + height: modeTrack.height + Label { + anchors.centerIn: parent + anchors.verticalCenterOffset: root.inkNudge(0.92) + text: modelData.t + font.bold: true + font.pointSize: root.titleSize * 0.92 + color: root.stMode === modelData.m ? "#ffffff" : root.cDim2 + Behavior on color { ColorAnimation { duration: 220 } } + } + MouseArea { + anchors.fill: parent + onClicked: ctrlCode("(ctrl-mode " + modelData.m + ")") + } + } + } + } + } + } + + GridLayout { + Layout.fillWidth: true + Layout.topMargin: Math.round(14 * ctlScroll.hS) + enabled: !root.isSlave + opacity: root.isSlave ? 0.3 : 1.0 + columns: 2 + rowSpacing: Math.round(16 * ctlScroll.hS) + columnSpacing: Math.round(14 * ctlScroll.hS) + + Repeater { + model: [ + { t: "LOCK", on: root.stLock, col: "#c31f1f", fg: "#ffffff", + cmd: "(ctrl-lock " + (root.stLock ? "false" : "true") + ")", live: true }, + { t: "SECRET", on: root.stSecret, col: "#771ca2", fg: "#ffffff", + cmd: "(ctrl-secret " + (root.stSecret ? "false" : "true") + ")", + live: root.stSecretAllow }, + { t: "LIGHT", on: root.stLight, col: "#e49e26", fg: root.stLight ? "#1e1a10" : "#ffffff", + cmd: "(ctrl-light " + (root.stLight ? "false" : "true") + ")", live: true }, + { t: "CRUISE", on: root.stCruiseEn, col: "#118579", fg: "#ffffff", + cmd: "(ctrl-cruise " + (root.stCruiseEn ? "false" : "true") + ")", live: root.stCruiseAllow } + ] + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: Math.round(64 * ctlScroll.hS) + 10 + radius: 14 + // a switched off function reads as off, not as dimmed + // colour - the state it was left in says nothing once + // the function itself is gone + readonly property bool lit: modelData.live && modelData.on + color: lit ? modelData.col : root.cCtl + opacity: modelData.live ? 1.0 : 0.35 + scale: cellTouch.pressed ? 0.975 : 1.0 + Behavior on color { ColorAnimation { duration: 180 } } + Behavior on opacity { NumberAnimation { duration: 180 } } + Behavior on scale { NumberAnimation { duration: 90 } } + + Label { + anchors.centerIn: parent + text: modelData.t + font.bold: true + font.pointSize: root.titleSize + color: parent.lit ? modelData.fg : root.cInk + } + MouseArea { + id: cellTouch + anchors.fill: parent + enabled: modelData.live + onClicked: ctrlCode(modelData.cmd) + } + } + } + } + } + } + } + } + } + + Page { + enabled: !isSlave + + ScrollView { + id: genScroll + anchors.fill: parent + contentWidth: availableWidth + contentHeight: genCol.implicitHeight + clip: true + // Both explicit: a ColumnLayout sized off parent.width leaves + // ScrollView deriving contentHeight from an item whose own parent + // is height driven BY contentHeight, and it latches short. Overshoot + // hid that, because a drag always did something. + // nothing to scroll means nothing moves - the flickable a + // ScrollView makes still rubber bands a page that already fits + Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds + + ColumnLayout { + id: genCol + width: genScroll.availableWidth + spacing: 4 + + Label { text: "General"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 12; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card1.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card1 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Startup Mode"; Layout.fillWidth: true } + ComboBox { + id: bootMode + Layout.preferredWidth: 100 + model: ["Eco", "Drive", "Sport"] + currentIndex: 1 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + Label { text: "The mode the scooter starts in when the VESC is turned on."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + enabled: secretEnabled.checked + Label { font.bold: true; text: "Disable Secret when Locked"; Layout.fillWidth: true } + CheckBox { id: secretExitOnLock; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Locking the scooter drops it back out of the secret modes."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Start Speed (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: minSpeed; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Kick-off speed. Throttle will not work below this value."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Idle Display"; Layout.fillWidth: true } + CheckBox { id: showBatteryInIdle; text: "Normal"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: showBatterySecret; text: "Secret"; spacing: 4 + enabled: secretEnabled.checked + topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + } + Label { text: "Show a different value on the dash while standing still."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Label { + text: "Idle Display Shows" + font.bold: true + Layout.fillWidth: true + enabled: showBatteryInIdle.checked || showBatterySecret.checked + } + ComboBox { + id: idleDisplay + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + Layout.preferredWidth: 130 + enabled: showBatteryInIdle.checked || showBatterySecret.checked + model: ["Battery %", "Battery V", "VESC " + root.degUnit, "Motor " + root.degUnit] + } + } + Label { text: "Select what to show while standing still."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Alarm"; Layout.fillWidth: true } + CheckBox { id: alarmTone; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Enable the alarm when the scooter is locked."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + + } + } + Label { text: "Lights"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: cardLights.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: cardLights + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Auto Headlight"; Layout.fillWidth: true } + CheckBox { id: lightOnBoot; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Headlight comes on by itself every time the scooter starts."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Headlight OFF when Locked"; Layout.fillWidth: true } + CheckBox { id: headOffLock; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Headlight is turned OFF when locked, and restored when unlocked."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Always ON Tail Light"; Layout.fillWidth: true; enabled: rearLightEnable.checked } + CheckBox { id: autoTaillight; spacing: 4; enabled: rearLightEnable.checked; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Tail light stays lit the whole time the scooter is on, not only under braking."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Tail Light OFF when Locked"; Layout.fillWidth: true; enabled: rearLightEnable.checked } + CheckBox { id: tailOffLock; spacing: 4; enabled: rearLightEnable.checked; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Tail light is turned OFF when locked, and restored when unlocked."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + enabled: rearLightEnable.checked + Label { font.bold: true; text: "Tail Light Brightness (1-10)"; Layout.fillWidth: true } + ComboBox { + id: tailBright + Layout.preferredWidth: 100 + model: ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"] + enabled: rearLightEnable.checked + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + Label { text: "Tail light brightness, in ten steps."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Brake Light"; Layout.fillWidth: true; enabled: rearLightEnable.checked } + ComboBox { + id: brakeLightMode + Layout.preferredWidth: 100 + model: ["On", "Blink", "Off"] + enabled: rearLightEnable.checked + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + Label { text: "Select the brake light effect when the brake is pressed."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + } + } + Label { text: "Gestures"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card2.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card2 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + + Label { text: "Lock"; font.bold: true; Layout.bottomMargin: 3 } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + CheckBox { id: lockBrake; text: "Brake"; checked: true; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: lockThrottle; text: "Throttle"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + Item { Layout.fillWidth: true } + ComboBox { + id: lockPresses + Layout.preferredWidth: 72 + model: ["1", "2", "3", "4", "5"] + currentIndex: 1 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + + Label { text: "Modes"; font.bold: true; Layout.bottomMargin: 3 } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + CheckBox { id: modeBrake; text: "Brake"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: modeThrottle; text: "Throttle"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: modeLocked; text: "Locked"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + Item { Layout.fillWidth: true } + ComboBox { + id: modePresses + Layout.preferredWidth: 72 + model: ["No", "1", "2", "3", "4", "5"] + currentIndex: 2 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + + Label { text: "Headlight"; font.bold: true; Layout.bottomMargin: 3 } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + CheckBox { id: lightBrake; text: "Brake"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: lightThrottle; text: "Throttle"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: lightLocked; text: "Locked"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + Item { Layout.fillWidth: true } + ComboBox { + id: lightPresses + Layout.preferredWidth: 72 + model: ["No", "1", "2", "3", "4", "5"] + currentIndex: 1 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + enabled: secretEnabled.checked + + Label { text: "Secret"; font.bold: true; Layout.bottomMargin: 3 } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + CheckBox { id: secretBrake; text: "Brake"; checked: true; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: secretThrottle; text: "Throttle"; checked: true; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: secretRequiresLock; text: "Locked"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + Item { Layout.fillWidth: true } + ComboBox { + id: secretPresses + Layout.preferredWidth: 72 + model: ["No", "1", "2", "3", "4", "5"] + currentIndex: 1 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 4 + enabled: secretEnabled.checked + + Label { text: "Secret OFF"; font.bold: true; Layout.bottomMargin: 3 } + + RowLayout { + Layout.fillWidth: true + spacing: 6 + CheckBox { id: secretOffBrake; text: "Brake"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: secretOffThrottle; text: "Throttle"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: secretOffRequiresLock; text: "Locked"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + Item { Layout.fillWidth: true } + ComboBox { + id: secretOffPresses + Layout.preferredWidth: 72 + model: ["No", "1", "2", "3", "4", "5"] + currentIndex: 3 + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + } + + Label { text: "Each gesture is a number of power button presses with the listed levers held down at the same time."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: 6; Layout.bottomMargin: 6 } + } + } + } + } + } + + Page { + enabled: !isSlave + + ScrollView { + id: modScroll + anchors.fill: parent + contentWidth: availableWidth + contentHeight: modCol.implicitHeight + clip: true + // Both explicit: a ColumnLayout sized off parent.width leaves + // ScrollView deriving contentHeight from an item whose own parent + // is height driven BY contentHeight, and it latches short. Overshoot + // hid that, because a drag always did something. + // nothing to scroll means nothing moves - the flickable a + // ScrollView makes still rubber bands a page that already fits + Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds + + ColumnLayout { + id: modCol + width: modScroll.availableWidth + spacing: 4 + + Label { text: "Parameters"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 12; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: cardApply.implicitHeight + 22 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: cardApply + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + anchors.topMargin: 8 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Secret Modes"; Layout.fillWidth: true } + CheckBox { id: secretEnabled; checked: true; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + + Label { text: "A second set of limits, reached with a button combination."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Maximum Speed"; Layout.fillWidth: true } + CheckBox { id: applySpeed; checked: true; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Set the maximum speed for each mode."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Current Scaling"; Layout.fillWidth: true } + CheckBox { id: applyCurrent; checked: true; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Scaling of the motor current configured in VESC."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Power"; Layout.fillWidth: true } + CheckBox { id: applyWatts; checked: true; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Set maximum power in watts per mode."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Field Weakening"; Layout.fillWidth: true } + CheckBox { id: applyFw; checked: true; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Maximum field weakening current. Raises top speed."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Overmodulation Factor"; Layout.fillWidth: true } + CheckBox { id: applyOm; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Sets the FOC overmodulation factor. Raises top speed at the expense of voltage distortion."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + + } + } + + Label { text: "Normal"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 12; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card3.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card3 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Item { Layout.preferredWidth: 106; Layout.rightMargin: 6 } + Label { text: "Eco"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + Label { text: "Drive"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + Label { text: "Sport"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + } + + RowLayout { + visible: applySpeed.checked + Layout.fillWidth: true + Label { text: useMph.checked ? "Speed (mph)" : "Speed (km/h)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: ecoSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: driveSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: sportSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyCurrent.checked + Layout.fillWidth: true + Label { text: "Current (%)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: ecoCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(ecoCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: driveCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(driveCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: sportCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(sportCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyWatts.checked + Layout.fillWidth: true + Label { text: "Power (W)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: ecoWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: driveWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: sportWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyFw.checked + Layout.fillWidth: true + Label { text: "Field W. (A)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: ecoFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: driveFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: sportFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyOm.checked + Layout.fillWidth: true + Label { text: "Overmod."; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: ecoOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(ecoOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: driveOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(driveOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: sportOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(sportOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + } + } + Label { visible: secretEnabled.checked; text: "Secret"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + visible: secretEnabled.checked + Layout.fillWidth: true + Layout.preferredHeight: card4.implicitHeight + 22 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card4 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + anchors.topMargin: 8 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Item { Layout.preferredWidth: 106; Layout.rightMargin: 6 } + Label { text: "Eco"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + Label { text: "Drive"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + Label { text: "Sport"; font.bold: true; Layout.fillWidth: true; Layout.preferredWidth: 50; horizontalAlignment: Text.AlignHCenter } + } + + RowLayout { + visible: applySpeed.checked + Layout.fillWidth: true + Label { text: useMph.checked ? "Speed (mph)" : "Speed (km/h)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: secretEcoSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretDriveSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretSportSpeed; horizontalAlignment: TextInput.AlignHCenter; enabled: applySpeed.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyCurrent.checked + Layout.fillWidth: true + Label { text: "Current (%)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: secretEcoCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(secretEcoCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretDriveCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(secretDriveCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretSportCurrent; horizontalAlignment: TextInput.AlignHCenter; enabled: applyCurrent.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampPct(secretSportCurrent); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyWatts.checked + Layout.fillWidth: true + Label { text: "Power (W)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: secretEcoWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretDriveWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretSportWatts; horizontalAlignment: TextInput.AlignHCenter; enabled: applyWatts.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyFw.checked + Layout.fillWidth: true + Label { text: "Field W. (A)"; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: secretEcoFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretDriveFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretSportFw; horizontalAlignment: TextInput.AlignHCenter; enabled: applyFw.checked; Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + visible: applyOm.checked + Layout.fillWidth: true + Label { text: "Overmod."; font.bold: true; Layout.preferredWidth: 106; Layout.preferredHeight: 36; Layout.rightMargin: 6; verticalAlignment: Text.AlignVCenter } + TextField { font.pointSize: root.titleSize * 0.95; id: secretEcoOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(secretEcoOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretDriveOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(secretDriveOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + TextField { font.pointSize: root.titleSize * 0.95; id: secretSportOm; horizontalAlignment: TextInput.AlignHCenter; enabled: applyOm.checked; text: "1.000"; onEditingFinished: clampOm(secretSportOm); Layout.fillWidth: true; Layout.preferredWidth: 50; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + } + } + + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.preferredHeight: modeNotes.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: modeNotes + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 8 + + Label { text: "Every value here is applied to each controller separately. Two controllers on the CAN bus draw twice the watts and twice the current. Speed is not doubled - each one holds the same limit."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.bottomMargin: 6 } + Label { text: "Each switch under Parameters covers Normal and Secret together. Anything switched off is hidden here and left exactly as VESC has it."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true } + } + } + + } + } + } + + Page { + ScrollView { + id: setScroll + anchors.fill: parent + contentWidth: availableWidth + contentHeight: setCol.implicitHeight + clip: true + // Both explicit: a ColumnLayout sized off parent.width leaves + // ScrollView deriving contentHeight from an item whose own parent + // is height driven BY contentHeight, and it latches short. Overshoot + // hid that, because a drag always did something. + // nothing to scroll means nothing moves - the flickable a + // ScrollView makes still rubber bands a page that already fits + Component.onCompleted: contentItem.boundsBehavior = Flickable.StopAtBounds + + ColumnLayout { + id: setCol + width: setScroll.availableWidth + spacing: 4 + + Rectangle { + Layout.fillWidth: true + Layout.topMargin: 12 + Layout.preferredHeight: card0.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card0 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Label { text: "Model"; font.bold: true; Layout.fillWidth: true } + ComboBox { + id: modelBox + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + Layout.preferredWidth: 170 + model: ["G30", "M365/1S/PRO2", "Slave", "G2 (untested)"] + currentIndex: 2 + onActivated: root.modelPicked(index) + } + } + Label { text: "Select your dashboard/scooter model."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + enabled: !isSlave + + Label { text: "Throttle & Brake"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 12; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card5.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card5 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { text: "Software ADC"; font.bold: true; Layout.fillWidth: true } + CheckBox { id: softwareAdc; text: "Throttle"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + CheckBox { id: softwareAdc2; text: "Brake"; spacing: 4; topPadding: 0; bottomPadding: 0; leftPadding: 14; rightPadding: 14; indicator: Item { } background: Rectangle { radius: 9; implicitHeight: 36; color: parent.checked ? root.cAccentBg2 : root.cCard; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } } contentItem: Label { text: parent.text; horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter; elide: Text.ElideRight; font.weight: Font.DemiBold; font.pointSize: root.titleSize * 0.95; color: parent.checked ? root.cAccent : root.cDim } } + } + Label { text: "Throttle and brake come from the dashboard over UART. Turn this off when they are wired straight to the VESC's ADC pins."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + visible: softwareAdc.checked || softwareAdc2.checked + + Rectangle { Layout.fillWidth: true; Layout.topMargin: 14; height: 1; color: root.cDialog } + + Label { text: "Light Compensation"; font.bold: true; Layout.topMargin: 8 } + Label { text: "The dash lifts the lever voltages when the headlight is on. These cancel that out so the throttle feels the same either way."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: 2 } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + visible: softwareAdc.checked + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Throttle ADC1 (V)"; Layout.fillWidth: true } + Label { text: root.adcV1.toFixed(4) + "V"; Layout.preferredWidth: 100; horizontalAlignment: Text.AlignHCenter; font.bold: true; color: root.cAccent } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Throttle Offset (V)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: lightOffThr; horizontalAlignment: TextInput.AlignHCenter; text: "0.000"; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampOffsetV(lightOffThr); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Throttle Gain (k)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: lightGainThr; horizontalAlignment: TextInput.AlignHCenter; text: "1.000"; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampGain(lightGainThr); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 56 + Layout.topMargin: 4 + // stays enabled while THIS channel runs (so its own color + // shows, not the greyed-out disabled palette) - only the + // other channel's button locks out; re-tapping is a no-op + enabled: root.calibRunning === "" || root.calibRunning === "thr" + text: root.calibButtonText("thr") + font.bold: true + Material.foreground: root.calibRunning === "thr" ? "#d0faff" : root.cInk + background: Rectangle { + radius: 14 + color: root.calibRunning === "thr" ? "#118579" : root.cCtl + } + onClicked: root.calibStartChannel("thr") + } + + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + visible: softwareAdc2.checked + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Brake ADC2 (V)"; Layout.fillWidth: true } + Label { text: root.adcV2.toFixed(4) + "V"; Layout.preferredWidth: 100; horizontalAlignment: Text.AlignHCenter; font.bold: true; color: root.cAccent } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Brake Offset (V)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: lightOffBrk; horizontalAlignment: TextInput.AlignHCenter; text: "0.000"; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampOffsetV(lightOffBrk); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Brake Gain (k)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: lightGainBrk; horizontalAlignment: TextInput.AlignHCenter; text: "1.000"; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; onEditingFinished: clampGain(lightGainBrk); topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + Button { + Layout.fillWidth: true + Layout.preferredHeight: 56 + Layout.topMargin: 4 + enabled: root.calibRunning === "" || root.calibRunning === "brk" + text: root.calibButtonText("brk") + font.bold: true + Material.foreground: root.calibRunning === "brk" ? "#d0faff" : root.cInk + background: Rectangle { + radius: 14 + color: root.calibRunning === "brk" ? "#118579" : root.cCtl + } + onClicked: root.calibStartChannel("brk") + } + + } + } + + } + } + Label { text: "Gestures"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card6.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card6 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Disable Gestures above (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: buttonSpeed; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Button gestures are disabled above this speed."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + + } + } + Label { text: "Temperature"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card7.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card7 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Motor Temp Warning (" + root.degUnit + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: tempWarningMotor; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "VESC (MOSFET) Temp Warning (" + root.degUnit + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: tempWarningFet; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + // the pack temperature comes off the BMS, so these + // do nothing until Use VESC BMS for Dash is on + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Battery Hot Warning (" + root.degUnit + ")"; Layout.fillWidth: true; enabled: bmsSoc.checked } + TextField { font.pointSize: root.titleSize * 0.95; id: bmsTempHi; enabled: bmsSoc.checked; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Battery Cold Warning (" + root.degUnit + ")"; Layout.fillWidth: true; enabled: bmsSoc.checked } + TextField { font.pointSize: root.titleSize * 0.95; id: bmsTempLo; enabled: bmsSoc.checked; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Show a warning on the dash when a temperature goes outside these limits."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + + } + } + Label { text: "Miscellaneous"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card8.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card8 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Use Imperial Units"; Layout.fillWidth: true } + CheckBox { + id: useMph + spacing: 4 + // convert already-shown speed fields on user toggle only + onCheckedChanged: if (root.settingsLoaded) { root.convertSpeedFields(checked); root.convertTempFields(checked) } + padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } + } + } + Label { text: "Change values to imperial units everywhere."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Use VESC BMS"; Layout.fillWidth: true } + CheckBox { id: bmsSoc; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Battery info is read from the VESC BMS."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { + Layout.fillWidth: true + font.bold: true + wrapMode: Text.WordWrap + text: (root.isSlave ? "App Support" + : (modelBox.currentIndex === 1 ? "Xiaomi" : "Segway") + + " App Support (exp.)") + } + CheckBox { id: appEnable; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Enable the original Segway/Xiaomi and third-party apps to connect over the dashboard's BLE."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "App pairing PIN"; Layout.fillWidth: true } + TextField { + id: appPin + horizontalAlignment: TextInput.AlignHCenter + enabled: appEnable.checked + Layout.preferredWidth: 100 + maximumLength: 6 + inputMethodHints: Qt.ImhDigitsOnly + topPadding: 0 + bottomPadding: 0 + leftPadding: 8 + rightPadding: 8 + verticalAlignment: TextInput.AlignVCenter + validator: RegularExpressionValidator { regularExpression: /[0-9]{0,6}/ } + background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } + } + } + Label { text: "The six digits the phone app asks for when it connects."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Tail Light Output (Servo/PPM)"; Layout.fillWidth: true } + CheckBox { id: rearLightEnable; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Control rear light using MOSFET. Check README for more info."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Dashboard Power Pin (exp.)"; Layout.fillWidth: true } + ComboBox { + id: dashPowerOut + Layout.preferredWidth: 100 + model: ["Off", "ADC1", "ADC2"] + background: Rectangle { radius: 10; color: root.cCtl; implicitHeight: 34 } + popup.background: Rectangle { radius: 12; color: root.cCard; border.width: 1; border.color: root.cEdge } + popup.padding: 6 + popup.height: popup.contentItem.implicitHeight + 12 + topInset: 0 + bottomInset: 0 + topPadding: 0 + bottomPadding: 0 + implicitHeight: 34 + font.weight: Font.Normal; font.pointSize: root.titleSize * 0.95 + Component.onCompleted: { popup.contentItem.boundsBehavior = Flickable.StopAtBounds; popup.contentItem.interactive = false } + delegate: comboItem + } + } + Label { text: "Turn off the dash/stepdown using an ADC pin. Check README for more info."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + Label { + visible: dashPowerOut.currentIndex === 1 && !softwareAdc.checked + text: "WARNING! You have no throttle!" + color: "#ff5252" + font.bold: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Label { + visible: dashPowerOut.currentIndex === 2 && !softwareAdc2.checked + text: "WARNING! Cruise control cannot be stopped when braking!" + color: "#ff5252" + font.bold: true + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + } + } + Label { text: "Cruise control (exp.)"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card9.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card9 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Cruise Control"; Layout.fillWidth: true } + CheckBox { id: cruiseEnable; spacing: 4; padding: 7; indicator: Rectangle { implicitWidth: 40; implicitHeight: 22; x: parent.leftPadding; y: parent.height / 2 - height / 2; radius: 11; color: parent.checked ? root.cAccentBg : root.cTrack; opacity: parent.enabled ? 1 : 0.45; Behavior on color { ColorAnimation { duration: 140 } } Rectangle { y: 3; width: 16; height: 16; radius: 8; color: parent.parent.checked ? root.cAccent : root.cDim2; x: parent.parent.checked ? 21 : 3; Behavior on color { ColorAnimation { duration: 140 } } Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } } } } + } + Label { text: "Enables the use of Cruise Control. Can be quickly toggled on the dash."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + enabled: cruiseEnable.checked + Label { font.bold: true; text: "Activation Delay (s)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: cruiseDelay; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "How long a steady speed must be held before cruise control takes over."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + enabled: cruiseEnable.checked + Label { font.bold: true; text: "Speed Deviation (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: cruiseDeviation; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "The largest speed change still counted as steady."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + enabled: cruiseEnable.checked + Label { font.bold: true; text: "Min. Activation Speed (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: cruiseMinSpeed; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Lowest speed at which cruise control can trigger."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + enabled: cruiseEnable.checked + Label { font.bold: true; text: "Max. Activation Speed (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: cruiseMaxSpeed; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Highest speed at which cruise control can trigger."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + + } + } + Label { text: "Alarm"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card10.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card10 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Speed Trigger (" + (useMph.checked ? "mph" : "km/h") + ")"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: alarmSpeedThreshold; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Speed above which the alarm is triggered."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Gyro Trigger (deg/s)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: alarmGyroThreshold; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Degrees above which the alarm is triggered."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6 } + + RowLayout { + Layout.fillWidth: true + Layout.preferredHeight: 40 + Label { font.bold: true; text: "Volume (V)"; Layout.fillWidth: true } + TextField { font.pointSize: root.titleSize * 0.95; id: alarmVoltage; horizontalAlignment: TextInput.AlignHCenter; Layout.preferredWidth: 100; maximumLength: 7; inputMethodHints: Qt.ImhFormattedNumbersOnly; topPadding: 0; bottomPadding: 0; leftPadding: 6; rightPadding: 6; verticalAlignment: TextInput.AlignVCenter; background: Rectangle { radius: 10; implicitHeight: 34; color: parent.enabled ? root.cCtl : root.cCard } } + } + Label { text: "Alarm loudness. Higher is louder."; opacity: 0.6; font.pointSize: root.titleSize * 0.85; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -6; Layout.bottomMargin: 6 } + } + } + + Label { text: "Backup"; font.bold: true; font.pointSize: root.titleSize * 0.92; font.capitalization: Font.AllUppercase; font.letterSpacing: 1; opacity: 0.8; Layout.topMargin: 26; Layout.leftMargin: 4 } + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: card11.implicitHeight + 28 + radius: 14 + color: root.cDeep + border.width: root.darkUi ? 0 : 1 + border.color: root.cEdge + ColumnLayout { + id: card11 + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 14 + spacing: 12 + + RowLayout { + Layout.fillWidth: true + spacing: 8 + Repeater { + model: [{ t: "Export", exp: true }, { t: "Import", exp: false }] + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 44 + radius: 12 + color: cfgTouch.pressed ? root.cCtlHi : root.cCtl + opacity: root.settingsLoaded ? 1.0 : 0.4 + Label { + anchors.centerIn: parent + text: modelData.t + font.bold: true + } + MouseArea { + id: cfgTouch + anchors.fill: parent + enabled: root.settingsLoaded + onClicked: { + if (modelData.exp) { + exportText.text = root.cfgExport() + exportDialog.open() + } else { + importText.text = "" + importDialog.open() + } + } + } + } + } + } + } + } + } + } + } + } + } + + RowLayout { + Layout.fillWidth: true + Layout.topMargin: 4 + Layout.bottomMargin: 2 + spacing: 8 + visible: swipeView.currentIndex !== 0 + + Repeater { + model: [ + { t: "Reset", act: 0, on: !root.saving, accent: false }, + { t: root.settingsLoaded ? "Load" : "Loading...", act: 1, on: true, accent: false }, + { t: root.saving ? "Saving..." : "Save", + act: 2, on: !root.saving, accent: true } + ] + Rectangle { + Layout.fillWidth: true + Layout.preferredWidth: 1 + Layout.preferredHeight: 46 + radius: 14 + // green only while something on screen has not reached the + // controller - otherwise Save sits dark like Reset and Load + color: modelData.accent && root.cfgDirty && !root.saving ? "#1b8728" : root.cWell + opacity: modelData.on ? 1.0 : 0.4 + scale: actTouch.pressed && modelData.on ? 0.975 : 1.0 + Behavior on opacity { NumberAnimation { duration: 150 } } + Behavior on scale { NumberAnimation { duration: 90 } } + + Label { + id: actLabel + anchors.centerIn: parent + text: modelData.t + font.bold: true + color: modelData.accent && root.cfgDirty && !root.saving ? "#ffffff" : root.cInk + } + MouseArea { + id: actTouch + anchors.fill: parent + enabled: modelData.on + onClicked: { + if (modelData.act === 0) resetDialog.open() + else if (modelData.act === 1) { resetSettingsLoad(); getSettings() } + else saveAllSettings() + } + } + } + } + } + } + + Connections { + target: mCommands + + function onValuesSetupReceived(values, mask) { + root.stMotAmps = values.current_motor + root.stTrip = values.tachometer_abs / 1000 + } + + function onDecodedAdcReceived(value, voltage, value2, voltage2) { + root.adcV1 = voltage + root.adcV2 = voltage2 + } + + function onCustomAppDataReceived(data) { + var message = data.toString().trim() + + if (message.startsWith("state ")) { + root.statePending = false + applyStateLine(message) + return + } + + if (message === "ack") { + root.stepDone() + return + } + + if (message.startsWith("calib-stage ")) { + // ["calib-stage", "prep"/"settle"/"measure", phaseLabel] mid-run, + // the sampling stages also carry [n, total]; or + // ["calib-stage", "release"/"idle", channel] for the tail end + var sp = message.split(" ") + var stage = sp[1] + if (stage === "prep" || stage === "settle" || stage === "measure") { + root.calibStage = stage + root.calibPhaseLabel = sp[2] + if (stage === "prep") root.calibRemaining = root.calibPrepDuration + else root.calibProgress = sp[3] + "/" + sp[4] + } else if (stage === "release") { + root.calibStage = "release" + root.calibRemaining = root.calibReleaseDuration + } else if (stage === "idle") { + // release grace period elapsed - real control has resumed + root.calibRunning = "" + root.calibStage = "idle" + } + return + } + if (message.startsWith("calib-progress ")) { + return // covered by the live step instruction; nothing to do + } + if (message.startsWith("calib-result ")) { + // channel stays "running" through the release grace period - + // cleared later by the "calib-stage idle" message above + // ["calib-result", "thr"/"brk", offset, gain, offRel, offFull, onRel, onFull] + var rp = message.split(" ") + if (rp[1] === "thr") { + setOffsetV(lightOffThr, rp[2]) + setGain(lightGainThr, rp[3]) + } else { + setOffsetV(lightOffBrk, rp[2]) + setGain(lightGainBrk, rp[3]) + } + root.cfgDirty = true + return + } + if (message === "calib-refused") { + calibFail(root.calibRunning, "Needs power on and standstill") + return + } + if (message === "calib-aborted") { + calibFail(root.calibRunning, "Cancelled - nothing changed") + return + } + if (message.startsWith("calib-error ")) { + // the script only gets here when released and full-press read + // nearly the same - the lever never moved, or not far enough + calibFail(message.split(" ")[1] === "brk" ? "brk" : "thr", + "Failed - lever not held fully pressed") + return + } + + if (message.startsWith("model ") + || message.startsWith("general ") + || message.startsWith("temps ") + || message.startsWith("modes ") + || message.startsWith("secret ") + || message.startsWith("apply ") + || message.startsWith("gesture ") + || message.startsWith("misc ") + || message.startsWith("rear ") + || message.startsWith("cruise ") + || message.startsWith("alarm ")) { + applySettingsLine(message) + } else if (message === "model-ok") { + root.stepDone() + loadedModel = modelBox.currentIndex + resetSettingsLoad() + VescIf.emitStatusMessage("Model saved, restarting...", true) + mCommands.lispSetRunning(false) + restartTimer.start() + } else if (message === "reset-ok") { + root.stepDone() + // reset takes the model back to Slave, which only applies on a restart + modelBox.currentIndex = 2 + loadedModel = 2 + root.saving = false + saveTimeout.stop() + resetSettingsLoad() + VescIf.emitStatusMessage("Settings reset, restarting...", true) + mCommands.lispSetRunning(false) + restartTimer.start() + } else if (message === "ok") { + root.stepDone() + root.saving = false + root.cfgDirty = false + saveTimeout.stop() + VescIf.emitStatusMessage("Scooter settings saved.", true) + } + } + } +} diff --git a/scooter_dashboard_support/version b/scooter_dashboard_support/version new file mode 100644 index 000000000..69df05f33 --- /dev/null +++ b/scooter_dashboard_support/version @@ -0,0 +1 @@ +4.3