diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5706dce --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.pio/ +.vscode/ +*.pyc diff --git a/CONTEXT.md b/CONTEXT.md index 45bedf3..0335bde 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,6 +66,10 @@ The regulatory band plan the device transmits under (here EU868). It sets the al **Duty Cycle Budget**: The share of airtime the Region allows this device to transmit. When it's used up, outgoing traffic waits. +**Text Entry**: +When an App is editing text. During Text Entry, `;` `.` `,` `/` type their characters and Fn makes them arrows. Otherwise they are arrows on their own. +_Avoid_: edit mode, insert mode + **Compose Key**: The `opt` key used as a dead key. Pressing it and then a base letter types an accented character (e.g. `opt` `'` `e` → é). _Avoid_: modifier, alt diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ff012fb --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# roro9stack + +A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262**. It's a Meshtastic-compatible mesh messenger, plus Wi-Fi tools, IRC, GNSS and more. Licensed GPL-3.0. + +- Domain language: [CONTEXT.md](CONTEXT.md) +- Decisions: [docs/adr/](docs/adr/) +- Milestones: [docs/milestones/](docs/milestones/) + +## Requirements + +Only **Docker** is needed. PlatformIO and the ESP32 toolchain run inside a container, and are cached in the `roro9stack-pio` Docker volume. The first build downloads about 1 GB and takes a few minutes. + +## Build and test (local CI) + +```sh +scripts/ci.sh +``` + +This runs the host-side unit tests (`test/`, `native` environment), then builds the firmware. The output is `.pio/build/cardputer-adv/firmware.factory.bin`. + +## Flash + +1. Connect the Cardputer by USB-C. +2. Run: + + ```sh + scripts/flash.sh # auto-detects the port; or: scripts/flash.sh /dev/ttyACM1 + ``` + + This uploads the firmware, then opens the serial monitor. Quit the monitor with `Ctrl+C`. + +**If the upload can't connect,** put the device in download mode: hold **G0** (the button next to the screen) while plugging in USB, or while pressing reset. Then retry. + +**If you get "permission denied" on the port,** your user needs access to the serial device. Run this once, then log out and back in: + +```sh +sudo usermod -aG dialout "$USER" +``` + +## Development aids + +`scripts/serial_log.sh [seconds] [command…]` records the serial output, and can send commands to the firmware first. For example, `scripts/serial_log.sh 30 short sleep:12 burst` sets short screen timeouts, waits 12 s, then sends a burst of Toasts. + +| Command | Effect | +|---|---| +| `burst` | Publishes 5 Notifications at once | +| `key up\|down\|left\|right\|select\|back\|home` | Injects a logical key press | +| `sound on` / `sound off` | Toggles the Sound setting (beep + LED) | +| `short` / `normal` | Screen timeouts 5 s / 10 s, or 30 s / 60 s | + +`scripts/flash.sh` stops a running serial log first, since it would hold the port. diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..b67e2b4 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,13 @@ +# Build/test/flash environment for roro9stack. No toolchain needed on the host. +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git build-essential \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir platformio + +# Toolchains and libraries are cached in a named volume mounted here. +RUN mkdir -p /pio && chmod 777 /pio +ENV PLATFORMIO_CORE_DIR=/pio +WORKDIR /work diff --git a/docs/milestones/M0.md b/docs/milestones/M0.md new file mode 100644 index 0000000..baac211 --- /dev/null +++ b/docs/milestones/M0.md @@ -0,0 +1,89 @@ +# M0 — Skeleton: Launcher, Status Bar, Settings, battery + +**Status:** done on 2026-10-02, tagged `v0.1.0`. + +## Outcome + +Every "Done when" item below works on the device, apart from the gaps listed here. Changes made along the way: + +- **Not done, carried over:** + - **Charging indicator:** the battery voltage alone can't tell charging apart reliably. Revisit if the hardware exposes a charger status. + - **Waking from power-off with a keyboard key:** G0 only for now. + - **Status Bar placeholders** for GNSS, mesh, Wi-Fi and unread count are left out until those Services exist (M1, M2, M4). +- **Added:** + - **SD card erase**, in Settings → Storage. + - **A Notification wakes an Off screen** (dimmed) while its Toast shows. + - **Navigation arrows without Fn** outside Text Entry. + - **Serial dev commands** and `scripts/serial_log.sh`. +- **Measured on the device:** + - About 225 KB free heap with the 64 KB frame buffer. + - About 15 ms per frame. + - 19% of the app flash used. + +**Goal:** a firmware you can flash and hold. It boots to a Launcher, shows a live Status Bar, navigates with the keyboard and saves Settings. It proves the App/Service architecture that every later milestone plugs into. + +## Done when + +- Building, testing and flashing each work with one command, run inside a local Docker container (no toolchain on the host). +- First boot runs the setup wizard (names, Region, timezone), and later boots skip it. +- The Launcher lists Apps. Enter starts one, `` ` `` goes back, and Fn+`` ` `` returns home from anywhere. +- The Status Bar shows battery %, a charging indicator and the clock (relative until set), with placeholders for GNSS, mesh, Wi-Fi and unread count. +- The Settings App edits and persists: long and short name, Region, timezone, brightness, dim and off timeouts, sound on/off, and probe-request MAC handling (raw by default). +- An About screen shows the version, free heap, battery voltage, SD status and usage, and uptime. +- The screen dims at 30 s and turns off at 60 s. Any key wakes it without the key also acting on the App. +- A long press of G0 powers off (deep sleep). G0 or a key wakes the device. +- The Compose Key types accented characters in the line editor (`opt` `'` `e` → é). +- Toast Notifications work. A demo App can raise one, with a beep and LED flash. +- SD usage is watched: a Storage Warning appears once per boot above 80%. The Log-write cutoff flag is set at 90%, with no Log writers yet. + +## Out of scope for M0 + +Storage Clean-up screen (M1, once IRC Logs exist), any radio, GNSS, Wi-Fi. + +## Work breakdown + +1. **Project scaffold** + - PlatformIO project with the pioarduino platform (Arduino-ESP32 3.x on ESP-IDF 5.x). Board `m5stack-stamps3`, 8 MB partitions, USB-CDC on boot. + - Dependencies: `M5Cardputer` (pulls in M5Unified and M5GFX). + - `LICENSE` (GPL-3.0), `README.md` with build and flash steps, a version string injected from git at build time (semver tags). + - A `native` environment for host-side unit tests (Unity). + - **Local CI** in a Docker image with PlatformIO. One script builds the firmware and runs the native tests, and the same container flashes over USB. The repo is hosted on self-hosted Gitea, and a Gitea Actions workflow can reuse this image later. +2. **Hardware bring-up checks** (manual, on the device) + - Display, keyboard (TCA8418) and speaker through M5Cardputer. + - Confirm there's no PSRAM, and record the heap at boot. + - Investigate the G38 backlight/LED power-rail coupling, then decide how dimming works without killing the LED. + - Battery ADC on G10 (×2 divider): calibrate the voltage-to-% curve. + - SD card mount on the shared SPI bus (CS=12), behind a bus lock ready for the LoRa radio in M3. +3. **Core runtime** + - **Event bus:** Services publish events (battery changed, notification, storage threshold), and the UI consumes them on the UI task. + - **Service interface:** start, stop, periodic tick, state snapshot for the Status Bar. + - **App interface:** enter, exit, key event, draw. Plus a registry, so adding an App means one file and one registration line. + - **App lifecycle:** one foreground App; Home and Back handling. +4. **Services for M0** + - **Settings store:** typed keys in NVS (internal flash), with defaults and change events. + - **Battery Service:** sampled voltage, smoothed %, charging detection if the hardware allows it. + - **Clock Service:** no time source yet. API for "set from source X", plus relative-time formatting and Europe/Brussels conversion. + - **Storage Service:** SD present/absent, usage %, 80% and 90% thresholds raising events, Log-write permission flag. + - **Power Service:** dim and off timers, wake-key swallowing, G0 long-press → deep sleep. +5. **Widget kit** (ADR 0002): an off-screen buffer pushed to the display. + - Status Bar, list, text view, line editor (with the Compose Key), dialog and Toast. + - A Latin-1 font set. +6. **Input layer** + - Map key events to logical keys: arrows (Fn + `;` `.` `,` `/`), Back, Home, Select. + - Compose Key dead-key state machine. This is pure logic, unit-tested on the host. +7. **Apps:** Launcher, Settings (including About), first-boot wizard, and a hidden Demo App for exercising Toasts and widgets. +8. **Docs:** a short walkthrough for a first-time setup: install PlatformIO, USB permissions on Linux, flash, recover via download mode. + +## Host-tested logic (TDD candidates) + +- Compose Key state machine +- Battery voltage → % curve and smoothing +- Storage threshold and once-per-boot warning logic +- Relative-time formatting and timezone conversion +- Settings defaults and validation (e.g. Region must be confirmed before any transmit flag) + +## Risks to resolve early + +- **G38 shared rail:** if the backlight and LED really share power, "screen off" may also need to turn the LED off. +- **RAM headroom:** the off-screen buffer is 240×135×2 ≈ 64 KB at 16-bit, or about 32 KB at 8-bit. Measure the free heap now, because Wi-Fi + TLS (M1) is the tightest point. +- **Keyboard library maturity** for the ADV's TCA8418 in `M5Cardputer`. Fall back to Adafruit_TCA8418 directly if needed. diff --git a/lib/apps_model/src/settings_menu.cpp b/lib/apps_model/src/settings_menu.cpp new file mode 100644 index 0000000..3059ebe --- /dev/null +++ b/lib/apps_model/src/settings_menu.cpp @@ -0,0 +1,143 @@ +#include "settings_menu.h" + +#include "choices.h" + +namespace roro { + +namespace { + +using Row = SettingsMenu::Row; +using Kind = SettingsMenu::Kind; + +struct RowDef { + Row row; + Kind kind; + const char* label; +}; + +const RowDef kRows[] = { + {Row::LongName, Kind::Text, "Long name"}, {Row::ShortName, Kind::Text, "Short name"}, + {Row::Region, Kind::Choice, "Region"}, {Row::Timezone, Kind::Choice, "Timezone"}, + {Row::Brightness, Kind::Slider, "Brightness"}, {Row::DimTimeout, Kind::Choice, "Dim after"}, + {Row::OffTimeout, Kind::Choice, "Screen off after"}, {Row::Sound, Kind::Toggle, "Sound & LED"}, + {Row::ProbeMacs, Kind::Toggle, "Probe MACs"}, {Row::Storage, Kind::Page, "Storage"}, + {Row::About, Kind::Page, "About"}, +}; + +const int kDimSeconds[] = {10, 15, 30, 60, 120, 300}; +const int kOffSeconds[] = {30, 60, 120, 300, 600, 1800}; + +std::string formatSeconds(int s) { return s < 60 ? std::to_string(s) + " s" : std::to_string(s / 60) + " min"; } + +template +std::vector labels(const Choice (&table)[N]) { + std::vector out; + for (auto& c : table) out.push_back(c.label); + return out; +} + +template +std::vector durations(const int (&seconds)[N]) { + std::vector out; + for (int s : seconds) out.push_back(formatSeconds(s)); + return out; +} + +template +int indexOf(const Choice (&table)[N], const std::string& value) { + for (size_t i = 0; i < N; i++) + if (value == table[i].value) return static_cast(i); + return -1; +} + +template +int indexOf(const int (&table)[N], int value) { + for (size_t i = 0; i < N; i++) + if (table[i] == value) return static_cast(i); + return -1; +} + +} // namespace + +int SettingsMenu::count() const { return sizeof(kRows) / sizeof(kRows[0]); } +SettingsMenu::Row SettingsMenu::row(int i) const { return kRows[i].row; } +SettingsMenu::Kind SettingsMenu::kind(int i) const { return kRows[i].kind; } +std::string SettingsMenu::label(int i) const { return kRows[i].label; } + +std::string SettingsMenu::value(int i) const { + switch (row(i)) { + case Row::LongName: return settings_.getString(Setting::LongName); + case Row::ShortName: return settings_.getString(Setting::ShortName); + case Row::Region: return settings_.getString(Setting::Region); + case Row::Timezone: { + int c = currentChoice(i); + return c >= 0 ? kTimezones[c].label : "Custom"; + } + case Row::Brightness: return std::to_string(settings_.getInt(Setting::Brightness)) + "%"; + case Row::DimTimeout: return formatSeconds(settings_.getInt(Setting::DimTimeoutS)); + case Row::OffTimeout: return formatSeconds(settings_.getInt(Setting::OffTimeoutS)); + case Row::Sound: return settings_.getBool(Setting::Sound) ? "On" : "Off"; + case Row::ProbeMacs: return settings_.getBool(Setting::ProbeMacRaw) ? "Raw" : "Pseudonymised"; + default: return ""; + } +} + +std::vector SettingsMenu::choices(int i) const { + switch (row(i)) { + case Row::Region: return labels(kRegions); + case Row::Timezone: return labels(kTimezones); + case Row::DimTimeout: return durations(kDimSeconds); + case Row::OffTimeout: return durations(kOffSeconds); + default: return {}; + } +} + +int SettingsMenu::currentChoice(int i) const { + switch (row(i)) { + case Row::Region: return indexOf(kRegions, settings_.getString(Setting::Region)); + case Row::Timezone: return indexOf(kTimezones, settings_.getString(Setting::Timezone)); + case Row::DimTimeout: return indexOf(kDimSeconds, settings_.getInt(Setting::DimTimeoutS)); + case Row::OffTimeout: return indexOf(kOffSeconds, settings_.getInt(Setting::OffTimeoutS)); + default: return -1; + } +} + +std::string SettingsMenu::choose(int i, int c) { + switch (row(i)) { + case Row::Region: + settings_.setString(Setting::Region, kRegions[c].value); + settings_.setBool(Setting::RegionConfirmed, true); + return ""; + case Row::Timezone: settings_.setString(Setting::Timezone, kTimezones[c].value); return ""; + case Row::DimTimeout: + return settings_.setInt(Setting::DimTimeoutS, kDimSeconds[c]) ? "" : "Dimming must happen before screen off"; + case Row::OffTimeout: + return settings_.setInt(Setting::OffTimeoutS, kOffSeconds[c]) ? "" : "Screen off must come after dimming"; + default: return "Not a choice"; + } +} + +void SettingsMenu::toggle(int i) { + if (row(i) == Row::Sound) settings_.setBool(Setting::Sound, !settings_.getBool(Setting::Sound)); + if (row(i) == Row::ProbeMacs) settings_.setBool(Setting::ProbeMacRaw, !settings_.getBool(Setting::ProbeMacRaw)); +} + +void SettingsMenu::adjust(int i, int direction) { + if (row(i) != Row::Brightness) return; + int next = settings_.getInt(Setting::Brightness) + (direction > 0 ? 10 : -10); + if (next < 10) next = 10; + if (next > 100) next = 100; + settings_.setInt(Setting::Brightness, next); +} + +int SettingsMenu::maxBytes(int i) const { + return row(i) == Row::ShortName ? 4 : row(i) == Row::LongName ? 39 : 0; +} + +std::string SettingsMenu::setText(int i, const std::string& text) { + Setting s = row(i) == Row::ShortName ? Setting::ShortName : Setting::LongName; + if (settings_.setString(s, text)) return ""; + return "Must be 1 to " + std::to_string(maxBytes(i)) + " bytes"; +} + +} // namespace roro diff --git a/lib/apps_model/src/settings_menu.h b/lib/apps_model/src/settings_menu.h new file mode 100644 index 0000000..9f65cd7 --- /dev/null +++ b/lib/apps_model/src/settings_menu.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "settings.h" + +namespace roro { + +// What the Settings App lists: one row per user-facing setting (plus sub-pages), with readable +// values, choice lists and validation messages. Rendering and navigation live in the App. +class SettingsMenu { + public: + enum class Row { LongName, ShortName, Region, Timezone, Brightness, DimTimeout, OffTimeout, Sound, ProbeMacs, Storage, About }; + enum class Kind { Text, Choice, Toggle, Slider, Page }; + + explicit SettingsMenu(Settings& settings) : settings_(settings) {} + + int count() const; + Row row(int i) const; + Kind kind(int i) const; + std::string label(int i) const; + std::string value(int i) const; + + // Choice rows + std::vector choices(int i) const; + int currentChoice(int i) const; // -1 if the current value isn't one of the choices + std::string choose(int i, int choice); // empty on success, otherwise why it was refused + + // Toggle and Slider rows + void toggle(int i); + void adjust(int i, int direction); + + // Text rows + int maxBytes(int i) const; + std::string setText(int i, const std::string& text); // empty on success, otherwise why + + private: + Settings& settings_; +}; + +} // namespace roro diff --git a/lib/apps_model/src/setup_wizard.cpp b/lib/apps_model/src/setup_wizard.cpp new file mode 100644 index 0000000..919a9a6 --- /dev/null +++ b/lib/apps_model/src/setup_wizard.cpp @@ -0,0 +1,88 @@ +#include "setup_wizard.h" + +#include "choices.h" + +namespace roro { + +SetupWizard::SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName) + : settings_(settings), longName_(std::move(defaultLongName)), shortName_(std::move(defaultShortName)) {} + +int SetupWizard::choiceCount() const { + if (step_ == Step::Region) return sizeof(kRegions) / sizeof(kRegions[0]); + if (step_ == Step::Timezone) return sizeof(kTimezones) / sizeof(kTimezones[0]); + return 0; +} + +std::string SetupWizard::choiceLabel(int i) const { + return step_ == Step::Region ? kRegions[i].label : kTimezones[i].label; +} + +int SetupWizard::selectedChoice() const { return step_ == Step::Region ? region_ : timezone_; } + +void SetupWizard::selectChoice(int i) { + if (i < 0 || i >= choiceCount()) return; + (step_ == Step::Region ? region_ : timezone_) = i; +} + +void SetupWizard::enter(Step step) { + step_ = step; + error_.clear(); + if (step == Step::LongName) { + editor_ = LineEditor(39); + editor_.setText(longName_); + } else if (step == Step::ShortName) { + editor_ = LineEditor(4); + editor_.setText(shortName_); + } +} + +bool SetupWizard::next() { + switch (step_) { + case Step::Welcome: enter(Step::LongName); return true; + case Step::LongName: + if (editor_.text().empty()) { + error_ = "Please enter a name"; + return false; + } + longName_ = editor_.text(); + enter(Step::ShortName); + return true; + case Step::ShortName: + if (editor_.text().empty()) { + error_ = "Please enter 1 to 4 characters"; + return false; + } + shortName_ = editor_.text(); + enter(Step::Region); + return true; + case Step::Region: enter(Step::Timezone); return true; + case Step::Timezone: enter(Step::Done); return true; + case Step::Done: + save(); + finished_ = true; + return true; + } + return false; +} + +void SetupWizard::back() { + switch (step_) { + case Step::LongName: longName_ = editor_.text(); enter(Step::Welcome); break; + case Step::ShortName: shortName_ = editor_.text(); enter(Step::LongName); break; + case Step::Region: enter(Step::ShortName); break; + case Step::Timezone: enter(Step::Region); break; + case Step::Done: enter(Step::Timezone); break; + case Step::Welcome: break; + } +} + +void SetupWizard::save() { + settings_.setString(Setting::LongName, longName_); + settings_.setString(Setting::ShortName, shortName_); + settings_.setString(Setting::Region, kRegions[region_].value); + settings_.setString(Setting::Timezone, kTimezones[timezone_].value); + settings_.setBool(Setting::RegionConfirmed, true); + settings_.setBool(Setting::SetupDone, true); +} + +} // namespace roro diff --git a/lib/apps_model/src/setup_wizard.h b/lib/apps_model/src/setup_wizard.h new file mode 100644 index 0000000..28b9755 --- /dev/null +++ b/lib/apps_model/src/setup_wizard.h @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include "line_editor.h" +#include "settings.h" + +namespace roro { + +// The first-boot setup flow: names, Region confirmation, timezone. Nothing is saved until the +// last step, so an interrupted setup simply starts again at the next boot. +class SetupWizard { + public: + enum class Step { Welcome, LongName, ShortName, Region, Timezone, Done }; + + SetupWizard(Settings& settings, std::string defaultLongName, std::string defaultShortName); + + Step step() const { return step_; } + bool finished() const { return finished_; } + + // Name steps edit text; Region and Timezone steps pick a choice. + LineEditor& editor() { return editor_; } + int choiceCount() const; + std::string choiceLabel(int i) const; + int selectedChoice() const; + void selectChoice(int i); + + bool next(); // validates the step; false (with error()) if it can't advance + void back(); + const std::string& error() const { return error_; } + + private: + void enter(Step step); + void save(); + + Settings& settings_; + Step step_ = Step::Welcome; + bool finished_ = false; + std::string error_; + LineEditor editor_{39}; + std::string longName_, shortName_; + int region_ = 0; + int timezone_ = 0; +}; + +} // namespace roro diff --git a/lib/core/src/app.h b/lib/core/src/app.h new file mode 100644 index 0000000..8ebef71 --- /dev/null +++ b/lib/core/src/app.h @@ -0,0 +1,45 @@ +#pragma once + +#include + +#include "key_event.h" + +namespace roro { + +class Canvas; // provided by the widget kit + +// A foreground, user-facing program (see CONTEXT.md). Exactly one App is on screen at a time. +class App { + public: + virtual ~App() = default; + + virtual void onEnter() {} + virtual void onExit() {} + + // Return true if the key was consumed. An unconsumed Back leaves the App. + virtual bool onKey(const KeyEvent& event) { + (void)event; + return false; + } + + // True while the App is editing text: the arrow keys then type ; . , / and need Fn to move. + virtual bool textEntryActive() const { return false; } + + // Called every main-loop pass while in the foreground (e.g. to refresh live values). + virtual void update(uint32_t nowMs) { (void)nowMs; } + + virtual void draw(Canvas& canvas) = 0; + + void requestRedraw() { redraw_ = true; } + + bool consumeRedraw() { + bool r = redraw_; + redraw_ = false; + return r; + } + + private: + bool redraw_ = false; +}; + +} // namespace roro diff --git a/lib/core/src/app_manager.cpp b/lib/core/src/app_manager.cpp new file mode 100644 index 0000000..a0b2254 --- /dev/null +++ b/lib/core/src/app_manager.cpp @@ -0,0 +1,87 @@ +#include "app_manager.h" + +#include + +namespace roro { + +AppManager::AppManager(App& launcher) : launcher_(launcher), foreground_(&launcher) {} + +void AppManager::registerApp(const AppInfo& info) { apps_.push_back(info); } + +std::vector AppManager::visibleApps() const { + std::vector visible; + for (auto& info : apps_) + if (!info.hidden) visible.push_back(&info); + return visible; +} + +void AppManager::begin() { + foreground_ = &launcher_; + launcher_.onEnter(); + redraw_ = true; +} + +App* AppManager::find(const char* id) const { + for (auto& info : apps_) + if (std::strcmp(info.id, id) == 0) return info.app; + return nullptr; +} + +bool AppManager::open(const char* id) { + App* app = find(id); + if (!app || modal_) return false; + switchTo(*app); + return true; +} + +void AppManager::home() { + if (!modal_) switchTo(launcher_); +} + +bool AppManager::openModal(const char* id) { + App* app = find(id); + if (!app) return false; + switchTo(*app); + modal_ = true; + return true; +} + +void AppManager::endModal() { + modal_ = false; + switchTo(launcher_); +} + +void AppManager::handleKey(const KeyEvent& event) { + if (modal_) { + foreground_->onKey(event); + return; + } + if (event.key == Key::Home) { + home(); + return; + } + bool consumed = foreground_->onKey(event); + if (!consumed && event.key == Key::Back) home(); +} + +const char* AppManager::foregroundTitle() const { + for (auto& info : apps_) + if (info.app == foreground_) return info.title; + return nullptr; +} + +bool AppManager::takeRedraw() { + bool r = redraw_ | foreground_->consumeRedraw(); + redraw_ = false; + return r; +} + +void AppManager::switchTo(App& app) { + if (&app == foreground_) return; + foreground_->onExit(); + foreground_ = &app; + foreground_->onEnter(); + redraw_ = true; +} + +} // namespace roro diff --git a/lib/core/src/app_manager.h b/lib/core/src/app_manager.h new file mode 100644 index 0000000..317fe55 --- /dev/null +++ b/lib/core/src/app_manager.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include "app.h" + +namespace roro { + +struct AppInfo { + const char* id; + const char* title; + bool hidden; // not listed in the Launcher, but can still be opened + App* app; +}; + +// Owns which App is in the foreground and routes keys. Home always returns to the Launcher; +// Back is offered to the App first and returns to the Launcher if the App doesn't consume it. +class AppManager { + public: + explicit AppManager(App& launcher); + + void registerApp(const AppInfo& info); + std::vector visibleApps() const; + + void begin(); + bool open(const char* id); // refused while a modal App runs + void home(); + + // A modal App (e.g. the first-boot wizard) can't be left with Home or Back: those keys go to it, + // and it ends itself with endModal(), which returns to the Launcher. + bool openModal(const char* id); + void endModal(); + + void handleKey(const KeyEvent& event); + void update(uint32_t nowMs) { foreground_->update(nowMs); } + + App& foreground() const { return *foreground_; } + const char* foregroundTitle() const; // nullptr for the Launcher + + // True once after the screen needs redrawing (App switch, or the App asked for it). + bool takeRedraw(); + + private: + void switchTo(App& app); + App* find(const char* id) const; + + App& launcher_; + App* foreground_; + std::vector apps_; + bool redraw_ = true; + bool modal_ = false; +}; + +} // namespace roro diff --git a/lib/core/src/event.h b/lib/core/src/event.h new file mode 100644 index 0000000..e59d51c --- /dev/null +++ b/lib/core/src/event.h @@ -0,0 +1,43 @@ +#pragma once + +#include +#include + +namespace roro { + +enum class NotificationLevel : int32_t { Info, Warning, Message }; + +// Everything Services announce to the UI. Add new kinds before Count. +enum class EventType : uint8_t { + BatteryChanged, // a = percent, b = millivolts + Notification, // text = message, a = NotificationLevel + StorageThreshold, // a = usage percent, b = level now in effect (0 / 80 / 90 / 100) + SettingChanged, // a = Setting, text = storage key + Count +}; + +// Small, fixed-size and copyable so it can cross tasks through a queue without allocating. +struct Event { + static constexpr size_t kTextCapacity = 48; + + EventType type; + int32_t a = 0; + int32_t b = 0; + char text[kTextCapacity] = {}; + + static Event withValues(EventType type, int32_t a, int32_t b = 0) { + Event e{type}; + e.a = a; + e.b = b; + return e; + } + + static Event withText(EventType type, const char* text, int32_t a = 0) { + Event e{type}; + e.a = a; + std::strncpy(e.text, text, kTextCapacity - 1); + return e; + } +}; + +} // namespace roro diff --git a/lib/core/src/event_bus.cpp b/lib/core/src/event_bus.cpp new file mode 100644 index 0000000..41184d1 --- /dev/null +++ b/lib/core/src/event_bus.cpp @@ -0,0 +1,46 @@ +#include "event_bus.h" + +namespace roro { + +EventBus::EventBus(size_t capacity) : queue_(capacity) {} + +bool EventBus::publish(const Event& event) { + std::lock_guard lock(mutex_); + if (count_ == queue_.size()) { + dropped_++; + return false; + } + queue_[(head_ + count_) % queue_.size()] = event; + count_++; + return true; +} + +void EventBus::subscribe(EventType type, Handler handler) { + handlers_[static_cast(type)].push_back(std::move(handler)); +} + +size_t EventBus::dispatch() { + size_t pending; + { + std::lock_guard lock(mutex_); + pending = count_; + } + for (size_t i = 0; i < pending; i++) { + Event event; + { + std::lock_guard lock(mutex_); + event = queue_[head_]; + head_ = (head_ + 1) % queue_.size(); + count_--; + } + for (auto& handler : handlers_[static_cast(event.type)]) handler(event); + } + return pending; +} + +uint32_t EventBus::dropped() const { + std::lock_guard lock(mutex_); + return dropped_; +} + +} // namespace roro diff --git a/lib/core/src/event_bus.h b/lib/core/src/event_bus.h new file mode 100644 index 0000000..ef8d775 --- /dev/null +++ b/lib/core/src/event_bus.h @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include + +#include "event.h" + +namespace roro { + +// Services publish from any task; the UI task calls dispatch() to deliver events to subscribers. +// Subscribe only during setup, from the UI task. +class EventBus { + public: + using Handler = std::function; + + explicit EventBus(size_t capacity = 32); + + // Thread-safe. Returns false (and counts a drop) if the queue is full. + bool publish(const Event& event); + + void subscribe(EventType type, Handler handler); + + // Delivers the events queued before this call; events published meanwhile wait for the next one. + // Returns how many events were delivered. + size_t dispatch(); + + uint32_t dropped() const; + + private: + std::vector queue_; + size_t head_ = 0; + size_t count_ = 0; + uint32_t dropped_ = 0; + mutable std::mutex mutex_; + std::array, static_cast(EventType::Count)> handlers_; +}; + +} // namespace roro diff --git a/lib/core/src/key_event.h b/lib/core/src/key_event.h new file mode 100644 index 0000000..a6b69f6 --- /dev/null +++ b/lib/core/src/key_event.h @@ -0,0 +1,41 @@ +#pragma once + +#include + +namespace roro { + +// Logical keys, already translated from the physical keyboard (Fn combos, Esc position, etc.). +enum class Key : uint8_t { + Char, // a printable character in `ch` (Unicode code point, accents already composed) + Up, + Down, + Left, + Right, + Select, + Back, + Home, + Tab, + Delete, +}; + +struct KeyEvent { + Key key = Key::Char; + uint32_t ch = 0; + bool shift = false; + bool ctrl = false; + bool alt = false; + + static KeyEvent of(Key key) { + KeyEvent e; + e.key = key; + return e; + } + + static KeyEvent character(uint32_t codePoint) { + KeyEvent e; + e.ch = codePoint; + return e; + } +}; + +} // namespace roro diff --git a/lib/core/src/service.h b/lib/core/src/service.h new file mode 100644 index 0000000..85afa89 --- /dev/null +++ b/lib/core/src/service.h @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace roro { + +// A long-lived background capability (see CONTEXT.md). Services are ticked cooperatively from the +// main loop; one that needs real concurrency (e.g. the radio) may run its own task internally and +// report through the EventBus. +class Service { + public: + virtual ~Service() = default; + virtual const char* name() const = 0; + virtual void start() {} + virtual void stop() {} + virtual void tick(uint32_t nowMs) { (void)nowMs; } + virtual uint32_t tickIntervalMs() const { return 1000; } +}; + +} // namespace roro diff --git a/lib/core/src/service_manager.cpp b/lib/core/src/service_manager.cpp new file mode 100644 index 0000000..d5ab262 --- /dev/null +++ b/lib/core/src/service_manager.cpp @@ -0,0 +1,33 @@ +#include "service_manager.h" + +namespace roro { + +void ServiceManager::add(Service& service) { entries_.push_back({&service, 0, true}); } + +void ServiceManager::startAll(uint32_t nowMs) { + for (auto& e : entries_) { + e.service->start(); + e.lastTickMs = nowMs; + e.due = true; + } + running_ = true; +} + +void ServiceManager::stopAll() { + for (auto it = entries_.rbegin(); it != entries_.rend(); ++it) it->service->stop(); + running_ = false; +} + +void ServiceManager::tick(uint32_t nowMs) { + if (!running_) return; + for (auto& e : entries_) { + // Unsigned subtraction keeps working across the 49-day millis() wraparound. + if (e.due || nowMs - e.lastTickMs >= e.service->tickIntervalMs()) { + e.due = false; + e.lastTickMs = nowMs; + e.service->tick(nowMs); + } + } +} + +} // namespace roro diff --git a/lib/core/src/service_manager.h b/lib/core/src/service_manager.h new file mode 100644 index 0000000..5a666ac --- /dev/null +++ b/lib/core/src/service_manager.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include "service.h" + +namespace roro { + +class ServiceManager { + public: + void add(Service& service); + + // Starts services in the order added; each gets its first tick on the next tick() call. + void startAll(uint32_t nowMs); + // Stops services in reverse order. + void stopAll(); + + // Ticks every service whose interval has elapsed. Missed intervals are not replayed. + void tick(uint32_t nowMs); + + private: + struct Entry { + Service* service; + uint32_t lastTickMs; + bool due; + }; + std::vector entries_; + bool running_ = false; +}; + +} // namespace roro diff --git a/lib/input/src/key_mapper.cpp b/lib/input/src/key_mapper.cpp new file mode 100644 index 0000000..7c26726 --- /dev/null +++ b/lib/input/src/key_mapper.cpp @@ -0,0 +1,114 @@ +#include "key_mapper.h" + +#include + +namespace roro { + +namespace { + +constexpr char kArmed = '*'; + +bool isAccent(char c) { return c == '\'' || c == '`' || c == '^' || c == '"' || c == ',' || c == '~'; } + +// Latin-1 code point for accent + base letter, or 0. +uint32_t composeLatin1(char accent, char base) { + struct Entry { + char accent; + const char* bases; + const uint32_t* codes; + }; + static const uint32_t acute[] = {0xE1, 0xE9, 0xED, 0xF3, 0xFA, 0xFD, 0xC1, 0xC9, 0xCD, 0xD3, 0xDA, 0xDD}; + static const uint32_t grave[] = {0xE0, 0xE8, 0xEC, 0xF2, 0xF9, 0xC0, 0xC8, 0xCC, 0xD2, 0xD9}; + static const uint32_t circ[] = {0xE2, 0xEA, 0xEE, 0xF4, 0xFB, 0xC2, 0xCA, 0xCE, 0xD4, 0xDB}; + static const uint32_t diaer[] = {0xE4, 0xEB, 0xEF, 0xF6, 0xFC, 0xFF, 0xC4, 0xCB, 0xCF, 0xD6, 0xDC}; + static const uint32_t cedil[] = {0xE7, 0xC7}; + static const uint32_t tilde[] = {0xE3, 0xF1, 0xF5, 0xC3, 0xD1, 0xD5}; + static const Entry table[] = { + {'\'', "aeiouyAEIOUY", acute}, {'`', "aeiouAEIOU", grave}, {'^', "aeiouAEIOU", circ}, + {'"', "aeiouyAEIOU", diaer}, {',', "cC", cedil}, {'~', "anoANO", tilde}, + }; + for (auto& e : table) { + if (e.accent != accent) continue; + for (int i = 0; e.bases[i]; i++) + if (e.bases[i] == base) return e.codes[i]; + } + return 0; +} + +KeyEvent charEvent(uint32_t cp, const RawKeys& keys) { + KeyEvent e = KeyEvent::character(cp); + e.shift = keys.shift; + e.ctrl = keys.ctrl; + e.alt = keys.alt; + return e; +} + +} // namespace + +std::vector KeyMapper::update(const RawKeys& keys) { + std::vector out; + + if (keys.opt && !previous_.opt && keys.chars.empty()) { + compose_ = compose_ ? 0 : kArmed; // a second opt cancels + } + if (keys.enter && !previous_.enter) out.push_back(KeyEvent::of(Key::Select)); + if (keys.del && !previous_.del) out.push_back(KeyEvent::of(Key::Delete)); + if (keys.tab && !previous_.tab) out.push_back(KeyEvent::of(Key::Tab)); + + for (char c : keys.chars) { + bool wasHeld = std::find(previous_.chars.begin(), previous_.chars.end(), c) != previous_.chars.end(); + if (!wasHeld) onChar(c, keys, out); + } + + previous_ = keys; + return out; +} + +void KeyMapper::onChar(char c, const RawKeys& keys, std::vector& out) { + // Holding opt while typing the accent is the same as pressing opt first. + if (keys.opt && !compose_) compose_ = kArmed; + + if (compose_ == kArmed) { + if (isAccent(c)) { + compose_ = c; + return; + } + compose_ = 0; // not an accent: type it normally below + } else if (compose_) { + char accent = compose_; + compose_ = 0; + uint32_t composed = composeLatin1(accent, c); + if (composed) { + out.push_back(charEvent(composed, keys)); + } else { + out.push_back(charEvent(static_cast(accent), keys)); + out.push_back(charEvent(static_cast(c), keys)); + } + return; + } + + if (keys.fn || !textEntry_) { + switch (c) { + case ';': out.push_back(KeyEvent::of(Key::Up)); return; + case '.': out.push_back(KeyEvent::of(Key::Down)); return; + case ',': out.push_back(KeyEvent::of(Key::Left)); return; + case '/': out.push_back(KeyEvent::of(Key::Right)); return; + case '`': + if (keys.fn) { + out.push_back(KeyEvent::of(Key::Home)); + return; + } + break; + default: + if (keys.fn) return; // other Fn combos are unassigned + break; + } + } + if (c == '`') { + out.push_back(KeyEvent::of(Key::Back)); + return; + } + out.push_back(charEvent(static_cast(c), keys)); +} + +} // namespace roro diff --git a/lib/input/src/key_mapper.h b/lib/input/src/key_mapper.h new file mode 100644 index 0000000..4a4285c --- /dev/null +++ b/lib/input/src/key_mapper.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include "key_event.h" + +namespace roro { + +// The physical keyboard state as reported by the keyboard driver: every character key currently +// held (already shifted) plus the modifier and special keys. +struct RawKeys { + std::vector chars; + bool fn = false; + bool opt = false; + bool ctrl = false; + bool alt = false; + bool shift = false; + bool enter = false; + bool del = false; + bool tab = false; +}; + +// Turns keyboard state changes into logical KeyEvents: only newly pressed keys produce events; +// Fn + ; . , / are arrows, and so are ; . , / alone when no text is being entered; ` is Back and +// Fn + ` is Home; the Compose Key (opt) followed by an accent and a letter types the accented +// letter (opt ' e -> é). +class KeyMapper { + public: + std::vector update(const RawKeys& keys); + + // Whether the foreground App is editing text (default). Outside text entry, the arrow keys + // don't need Fn. + void setTextEntry(bool active) { textEntry_ = active; } + + // 0 when idle, '*' when opt was pressed, or the accent character waiting for its letter. + char pendingCompose() const { return compose_; } + + private: + void onChar(char c, const RawKeys& keys, std::vector& out); + + RawKeys previous_; + char compose_ = 0; + bool textEntry_ = true; +}; + +} // namespace roro diff --git a/lib/services/src/battery_estimator.cpp b/lib/services/src/battery_estimator.cpp new file mode 100644 index 0000000..9ba11b8 --- /dev/null +++ b/lib/services/src/battery_estimator.cpp @@ -0,0 +1,49 @@ +#include "battery_estimator.h" + +#include + +namespace roro { + +namespace { +struct Point { + int millivolts; + int percent; +}; +const Point kCurve[] = { + {3270, 0}, {3610, 5}, {3690, 10}, {3710, 15}, {3730, 20}, {3750, 25}, {3770, 30}, + {3790, 35}, {3800, 40}, {3820, 45}, {3840, 50}, {3850, 55}, {3870, 60}, {3910, 65}, + {3950, 70}, {3980, 75}, {4020, 80}, {4080, 85}, {4110, 90}, {4150, 95}, {4200, 100}, +}; +const size_t kPoints = sizeof(kCurve) / sizeof(kCurve[0]); +} // namespace + +int BatteryEstimator::percentFromMillivolts(int mv) { + if (mv <= kCurve[0].millivolts) return 0; + if (mv >= kCurve[kPoints - 1].millivolts) return 100; + for (size_t i = 1; i < kPoints; i++) { + if (mv <= kCurve[i].millivolts) { + const auto& lo = kCurve[i - 1]; + const auto& hi = kCurve[i]; + return lo.percent + (mv - lo.millivolts) * (hi.percent - lo.percent) / + (hi.millivolts - lo.millivolts); + } + } + return 100; +} + +void BatteryEstimator::addSample(int mv) { + samples_[next_] = mv; + next_ = (next_ + 1) % kWindow; + if (count_ < kWindow) count_++; +} + +int BatteryEstimator::millivolts() const { + if (count_ == 0) return 0; + // Median: ignores short sags (radio transmit bursts) that would drag an average down. + std::array sorted = samples_; + auto middle = sorted.begin() + count_ / 2; + std::nth_element(sorted.begin(), middle, sorted.begin() + count_); + return *middle; +} + +} // namespace roro diff --git a/lib/services/src/battery_estimator.h b/lib/services/src/battery_estimator.h new file mode 100644 index 0000000..6a8b7f8 --- /dev/null +++ b/lib/services/src/battery_estimator.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +// Smooths battery voltage samples and turns them into a charge percentage. +class BatteryEstimator { + public: + // Typical single-cell LiPo discharge curve under light load. + static int percentFromMillivolts(int millivolts); + + void addSample(int millivolts); + bool hasReading() const { return count_ > 0; } + int millivolts() const; // median of the recent samples + int percent() const { return percentFromMillivolts(millivolts()); } + + private: + static constexpr size_t kWindow = 8; + std::array samples_{}; + size_t next_ = 0; + size_t count_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/choices.h b/lib/services/src/choices.h new file mode 100644 index 0000000..7b440ac --- /dev/null +++ b/lib/services/src/choices.h @@ -0,0 +1,24 @@ +#pragma once + +namespace roro { + +struct Choice { + const char* label; + const char* value; +}; + +// Timezones offered in Settings and the setup wizard (POSIX TZ strings). +inline const Choice kTimezones[] = { + {"Brussels", "CET-1CEST,M3.5.0,M10.5.0/3"}, // also Paris, Amsterdam, Berlin... + {"London", "GMT0BST,M3.5.0/1,M10.5.0"}, + {"Lisbon", "WET0WEST,M3.5.0/1,M10.5.0"}, + {"Helsinki", "EET-2EEST,M3.5.0/3,M10.5.0/4"}, + {"UTC", "UTC0"}, +}; + +// Regions the radio may be used in (see Region in CONTEXT.md). +inline const Choice kRegions[] = { + {"EU868 (Europe, 869.5 MHz)", "EU868"}, +}; + +} // namespace roro diff --git a/lib/services/src/clock_model.cpp b/lib/services/src/clock_model.cpp new file mode 100644 index 0000000..4ef4544 --- /dev/null +++ b/lib/services/src/clock_model.cpp @@ -0,0 +1,49 @@ +#include "clock_model.h" + +#include +#include +#include + +namespace roro { + +bool ClockModel::set(int64_t utcSeconds, TimeSource source, uint32_t nowMs) { + if (source < source_) return false; + source_ = source; + utcAtSet_ = utcSeconds; + msAtSet_ = nowMs; + return true; +} + +int64_t ClockModel::utcNow(uint32_t nowMs) const { + // Unsigned subtraction handles the millis() wraparound. Uptime beyond 49 days between two + // sets would lose 49 days; sources refresh far more often than that. + return utcAtSet_ + static_cast(static_cast(nowMs - msAtSet_) / 1000); +} + +void ClockModel::applyTimezone(const char* posixTz) { + setenv("TZ", posixTz, 1); + tzset(); +} + +std::string ClockModel::formatLocalTime(int64_t utcSeconds) { + time_t t = static_cast(utcSeconds); + struct tm local; + localtime_r(&t, &local); + char buf[8]; + std::snprintf(buf, sizeof(buf), "%02d:%02d", local.tm_hour, local.tm_min); + return buf; +} + +std::string ClockModel::formatAge(int64_t seconds) { + char buf[24]; + if (seconds < 60) return "now"; + if (seconds < 3600) + std::snprintf(buf, sizeof(buf), "%d min ago", static_cast(seconds / 60)); + else if (seconds < 86400) + std::snprintf(buf, sizeof(buf), "%d h ago", static_cast(seconds / 3600)); + else + std::snprintf(buf, sizeof(buf), "%d d ago", static_cast(seconds / 86400)); + return buf; +} + +} // namespace roro diff --git a/lib/services/src/clock_model.h b/lib/services/src/clock_model.h new file mode 100644 index 0000000..adb4cbb --- /dev/null +++ b/lib/services/src/clock_model.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace roro { + +// Ordered by trust: a source can only replace the time set by an equal or less trusted one. +enum class TimeSource : uint8_t { None, Mesh, Ntp, Gnss }; + +// Wall-clock time on a device with no battery-backed clock: unset until a TimeSource provides +// it, then advanced from uptime. Stored in UTC; local display uses the POSIX TZ applied globally. +class ClockModel { + public: + // Returns false if a more trusted source already set the time. + bool set(int64_t utcSeconds, TimeSource source, uint32_t nowMs); + + bool isSet() const { return source_ != TimeSource::None; } + TimeSource source() const { return source_; } + int64_t utcNow(uint32_t nowMs) const; + + static void applyTimezone(const char* posixTz); + static std::string formatLocalTime(int64_t utcSeconds); // "HH:MM" + static std::string formatAge(int64_t seconds); // "now", "5 min ago", "3 h ago", "2 d ago" + + private: + TimeSource source_ = TimeSource::None; + int64_t utcAtSet_ = 0; + uint32_t msAtSet_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/key_value_store.h b/lib/services/src/key_value_store.h new file mode 100644 index 0000000..bbfb646 --- /dev/null +++ b/lib/services/src/key_value_store.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +namespace roro { + +// Persistent key/value storage (NVS on the device, a map in tests). Keys are at most 15 chars. +class KeyValueStore { + public: + virtual ~KeyValueStore() = default; + virtual bool getInt(const char* key, int32_t& out) = 0; + virtual bool getString(const char* key, std::string& out) = 0; + virtual void putInt(const char* key, int32_t value) = 0; + virtual void putString(const char* key, const std::string& value) = 0; +}; + +} // namespace roro diff --git a/lib/services/src/power_policy.cpp b/lib/services/src/power_policy.cpp new file mode 100644 index 0000000..0fd5b01 --- /dev/null +++ b/lib/services/src/power_policy.cpp @@ -0,0 +1,42 @@ +#include "power_policy.h" + +namespace roro { + +bool PowerPolicy::activity(uint32_t nowMs) { + bool swallow = state_ == ScreenState::Off; + lastActivityMs_ = nowMs; + state_ = ScreenState::On; + return swallow; +} + +ScreenState PowerPolicy::update(uint32_t nowMs) { + uint32_t idle = nowMs - lastActivityMs_; + state_ = idle >= offMs_ ? ScreenState::Off : idle >= dimMs_ ? ScreenState::Dimmed : ScreenState::On; + if (notifying_) { + if (static_cast(nowMs - notifyUntilMs_) >= 0) + notifying_ = false; + else if (state_ == ScreenState::Off) + state_ = ScreenState::Dimmed; + } + return state_; +} + +ButtonAction PowerButton::update(bool pressed, uint32_t nowMs) { + if (pressed && !pressed_) { + pressed_ = true; + longFired_ = false; + pressedAtMs_ = nowMs; + return ButtonAction::None; + } + if (pressed && !longFired_ && nowMs - pressedAtMs_ >= longMs_) { + longFired_ = true; + return ButtonAction::LongPress; + } + if (!pressed && pressed_) { + pressed_ = false; + return longFired_ ? ButtonAction::None : ButtonAction::ShortPress; + } + return ButtonAction::None; +} + +} // namespace roro diff --git a/lib/services/src/power_policy.h b/lib/services/src/power_policy.h new file mode 100644 index 0000000..1347b48 --- /dev/null +++ b/lib/services/src/power_policy.h @@ -0,0 +1,57 @@ +#pragma once + +#include + +namespace roro { + +enum class ScreenState : uint8_t { On, Dimmed, Off }; + +// Screen power from user activity. Services keep running whatever the screen does. +class PowerPolicy { + public: + PowerPolicy(uint32_t dimAfterMs, uint32_t offAfterMs) : dimMs_(dimAfterMs), offMs_(offAfterMs) {} + + void setTimeouts(uint32_t dimAfterMs, uint32_t offAfterMs) { + dimMs_ = dimAfterMs; + offMs_ = offAfterMs; + } + + // Records a key press. Returns true if the key only woke an Off screen and must not reach the App. + bool activity(uint32_t nowMs); + + // A Notification is showing until untilMs: an Off screen lights up Dimmed meanwhile. It's not + // user activity, so it doesn't restart the timeouts. + void notify(uint32_t nowMs, uint32_t untilMs) { + (void)nowMs; + notifyUntilMs_ = untilMs; + notifying_ = true; + } + + ScreenState update(uint32_t nowMs); + ScreenState state() const { return state_; } + + private: + uint32_t dimMs_; + uint32_t offMs_; + uint32_t lastActivityMs_ = 0; + uint32_t notifyUntilMs_ = 0; + bool notifying_ = false; + ScreenState state_ = ScreenState::On; +}; + +enum class ButtonAction : uint8_t { None, ShortPress, LongPress }; + +// G0 button: a long press fires as soon as the hold time is reached (power off); a short press on release. +class PowerButton { + public: + explicit PowerButton(uint32_t longPressMs) : longMs_(longPressMs) {} + ButtonAction update(bool pressed, uint32_t nowMs); + + private: + uint32_t longMs_; + bool pressed_ = false; + bool longFired_ = false; + uint32_t pressedAtMs_ = 0; +}; + +} // namespace roro diff --git a/lib/services/src/settings.cpp b/lib/services/src/settings.cpp new file mode 100644 index 0000000..9de2e93 --- /dev/null +++ b/lib/services/src/settings.cpp @@ -0,0 +1,118 @@ +#include "settings.h" + +namespace roro { + +namespace { + +enum class Kind : uint8_t { Bool, Int, String }; + +struct Definition { + const char* key; + Kind kind; + int32_t defaultInt; + const char* defaultString; + int32_t min; + int32_t max; // for strings: max length in bytes +}; + +// Order must match the Setting enum. +const Definition kDefinitions[] = { + {"setup_done", Kind::Bool, 0, nullptr, 0, 1}, + {"long_name", Kind::String, 0, "", 1, 39}, + {"short_name", Kind::String, 0, "", 1, 4}, + {"region", Kind::String, 0, "EU868", 1, 8}, + {"region_ok", Kind::Bool, 0, nullptr, 0, 1}, + {"timezone", Kind::String, 0, "CET-1CEST,M3.5.0,M10.5.0/3", 1, 63}, + {"brightness", Kind::Int, 60, nullptr, 10, 100}, + {"dim_s", Kind::Int, 30, nullptr, 5, 600}, + {"off_s", Kind::Int, 60, nullptr, 10, 3600}, + {"sound", Kind::Bool, 1, nullptr, 0, 1}, + {"probe_mac_raw", Kind::Bool, 1, nullptr, 0, 1}, +}; +static_assert(sizeof(kDefinitions) / sizeof(kDefinitions[0]) == static_cast(Setting::Count), + "every Setting needs a definition"); + +const char* const kKnownRegions[] = {"EU868"}; + +const Definition& def(Setting s) { return kDefinitions[static_cast(s)]; } + +} // namespace + +Settings::Settings(KeyValueStore& store, EventBus& bus) : store_(store), bus_(bus) {} + +const char* Settings::key(Setting s) { return def(s).key; } + +void Settings::load() { + for (size_t i = 0; i < values_.size(); i++) { + auto s = static_cast(i); + const auto& d = def(s); + auto& v = values_[i]; + if (d.kind == Kind::String) { + v.str = d.defaultString; + std::string stored; + if (store_.getString(d.key, stored) && validString(s, stored)) v.str = stored; + } else { + v.i = d.defaultInt; + int32_t stored; + if (store_.getInt(d.key, stored) && validInt(s, stored)) v.i = stored; + } + } +} + +int32_t Settings::getInt(Setting s) const { return values_[static_cast(s)].i; } +bool Settings::getBool(Setting s) const { return getInt(s) != 0; } +const std::string& Settings::getString(Setting s) const { return values_[static_cast(s)].str; } + +bool Settings::validInt(Setting s, int32_t value) const { + const auto& d = def(s); + if (d.kind == Kind::String || value < d.min || value > d.max) return false; + if (s == Setting::DimTimeoutS) return value < getInt(Setting::OffTimeoutS); + if (s == Setting::OffTimeoutS) return value > getInt(Setting::DimTimeoutS); + return true; +} + +bool Settings::validString(Setting s, const std::string& value) const { + const auto& d = def(s); + if (d.kind != Kind::String) return false; + if (value.size() < static_cast(d.min) || value.size() > static_cast(d.max)) return false; + if (s == Setting::Region) { + for (auto region : kKnownRegions) + if (value == region) return true; + return false; + } + return true; +} + +bool Settings::setInt(Setting s, int32_t value) { + if (def(s).kind == Kind::Bool) return false; + if (!validInt(s, value)) return false; + if (getInt(s) == value) return true; + values_[static_cast(s)].i = value; + store_.putInt(key(s), value); + changed(s); + return true; +} + +bool Settings::setBool(Setting s, bool value) { + if (def(s).kind != Kind::Bool) return false; + if (getBool(s) == value) return true; + values_[static_cast(s)].i = value ? 1 : 0; + store_.putInt(key(s), value ? 1 : 0); + changed(s); + return true; +} + +bool Settings::setString(Setting s, const std::string& value) { + if (!validString(s, value)) return false; + if (getString(s) == value) return true; + values_[static_cast(s)].str = value; + store_.putString(key(s), value); + changed(s); + return true; +} + +void Settings::changed(Setting s) { + bus_.publish(Event::withText(EventType::SettingChanged, key(s), static_cast(s))); +} + +} // namespace roro diff --git a/lib/services/src/settings.h b/lib/services/src/settings.h new file mode 100644 index 0000000..d709cb8 --- /dev/null +++ b/lib/services/src/settings.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include + +#include "event_bus.h" +#include "key_value_store.h" + +namespace roro { + +enum class Setting : uint8_t { + SetupDone, // bool: first-boot wizard completed + LongName, // string, 1..39 bytes + ShortName, // string, 1..4 bytes + Region, // string, a known Region ("EU868") + RegionConfirmed, // bool: nothing transmits until true + Timezone, // string, POSIX TZ + Brightness, // int, 10..100 % + DimTimeoutS, // int, 5..600, below OffTimeoutS + OffTimeoutS, // int, 10..3600, above DimTimeoutS + Sound, // bool + ProbeMacRaw, // bool: probe-request Logs keep raw MAC addresses + Count +}; + +// Typed, validated settings in internal flash. Every accepted change is persisted immediately and +// announced with a SettingChanged event; rejected values leave the current value untouched. +class Settings { + public: + Settings(KeyValueStore& store, EventBus& bus); + + // Reads every setting; missing or invalid stored values fall back to defaults. + void load(); + + int32_t getInt(Setting s) const; + bool getBool(Setting s) const; + const std::string& getString(Setting s) const; + + bool setInt(Setting s, int32_t value); + bool setBool(Setting s, bool value); + bool setString(Setting s, const std::string& value); + + bool canTransmit() const { return getBool(Setting::RegionConfirmed); } + + static const char* key(Setting s); + + private: + struct Value { + int32_t i = 0; + std::string str; + }; + + bool validInt(Setting s, int32_t value) const; + bool validString(Setting s, const std::string& value) const; + void changed(Setting s); + + KeyValueStore& store_; + EventBus& bus_; + std::array(Setting::Count)> values_; +}; + +} // namespace roro diff --git a/lib/services/src/storage_monitor.cpp b/lib/services/src/storage_monitor.cpp new file mode 100644 index 0000000..e09350f --- /dev/null +++ b/lib/services/src/storage_monitor.cpp @@ -0,0 +1,32 @@ +#include "storage_monitor.h" + +namespace roro { + +void StorageMonitor::update(bool present, uint64_t totalBytes, uint64_t usedBytes) { + StorageState next; + next.present = present && totalBytes > 0; + if (next.present) { + next.totalBytes = totalBytes; + next.usedBytes = usedBytes; + next.usedPercent = static_cast(usedBytes * 100 / totalBytes); + bool full = totalBytes - usedBytes < kFullReserveBytes || usedBytes >= totalBytes; + next.level = full ? 100 : next.usedPercent >= 90 ? 90 : next.usedPercent >= 80 ? 80 : 0; + next.logsAllowed = next.level < 90; + next.capturesAllowed = next.level < 100; + } + + bool levelChanged = next.present && (next.level != state_.level || !state_.present); + bool firstReading = !state_.present; + state_ = next; + + if (levelChanged && !(firstReading && next.level == 0)) + bus_.publish(Event::withValues(EventType::StorageThreshold, next.usedPercent, next.level)); + + if (next.present && next.level >= 80 && !warned_) { + warned_ = true; + bus_.publish(Event::withText(EventType::Notification, "SD card over 80% full", + static_cast(NotificationLevel::Warning))); + } +} + +} // namespace roro diff --git a/lib/services/src/storage_monitor.h b/lib/services/src/storage_monitor.h new file mode 100644 index 0000000..decef6b --- /dev/null +++ b/lib/services/src/storage_monitor.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "event_bus.h" + +namespace roro { + +struct StorageState { + bool present = false; + uint64_t totalBytes = 0; + uint64_t usedBytes = 0; + int usedPercent = 0; + int level = 0; // 0, 80, 90 or 100 (full) + bool logsAllowed = false; + bool capturesAllowed = false; +}; + +// Applies the SD card rules (see CONTEXT.md): Storage Warning once per boot past 80 %, Logs stop +// past 90 % to keep room for Captures, Captures stop when the card is full. +class StorageMonitor { + public: + static constexpr uint64_t kFullReserveBytes = 2ull * 1024 * 1024; + + explicit StorageMonitor(EventBus& bus) : bus_(bus) {} + + void update(bool present, uint64_t totalBytes, uint64_t usedBytes); + const StorageState& state() const { return state_; } + + private: + EventBus& bus_; + StorageState state_; + bool warned_ = false; +}; + +} // namespace roro diff --git a/lib/ui/src/dialog_model.h b/lib/ui/src/dialog_model.h new file mode 100644 index 0000000..3147278 --- /dev/null +++ b/lib/ui/src/dialog_model.h @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +#include "key_event.h" + +namespace roro { + +// A modal choice between buttons. The first button should be the safe one (e.g. "Cancel"): +// it's highlighted initially, and Back always cancels. +class DialogModel { + public: + static constexpr int kPending = -1; + static constexpr int kCancelled = -2; + + explicit DialogModel(std::vector buttons) : buttons_(std::move(buttons)) {} + + // Consumes every key while open. + bool onKey(const KeyEvent& e) { + if (result_ != kPending) return true; + int last = static_cast(buttons_.size()) - 1; + switch (e.key) { + case Key::Left: if (selected_ > 0) selected_--; break; + case Key::Right: if (selected_ < last) selected_++; break; + case Key::Tab: selected_ = selected_ == last ? 0 : selected_ + 1; break; + case Key::Select: result_ = selected_; break; + case Key::Back: result_ = kCancelled; break; + default: break; + } + return true; + } + + const std::vector& buttons() const { return buttons_; } + int selected() const { return selected_; } + int result() const { return result_; } + + private: + std::vector buttons_; + int selected_ = 0; + int result_ = kPending; +}; + +} // namespace roro diff --git a/lib/ui/src/line_editor.cpp b/lib/ui/src/line_editor.cpp new file mode 100644 index 0000000..2924527 --- /dev/null +++ b/lib/ui/src/line_editor.cpp @@ -0,0 +1,30 @@ +#include "line_editor.h" + +#include "utf8.h" + +namespace roro { + +void LineEditor::setText(const std::string& text) { + text_ = text.substr(0, utf8::clampToBoundary(text, max_)); + cursor_ = text_.size(); +} + +bool LineEditor::insert(uint32_t codePoint) { + std::string encoded = utf8::encode(codePoint); + if (text_.size() + encoded.size() > max_) return false; + text_.insert(cursor_, encoded); + cursor_ += encoded.size(); + return true; +} + +void LineEditor::backspace() { + if (cursor_ == 0) return; + size_t start = utf8::previous(text_, cursor_); + text_.erase(start, cursor_ - start); + cursor_ = start; +} + +void LineEditor::left() { cursor_ = utf8::previous(text_, cursor_); } +void LineEditor::right() { cursor_ = utf8::next(text_, cursor_); } + +} // namespace roro diff --git a/lib/ui/src/line_editor.h b/lib/ui/src/line_editor.h new file mode 100644 index 0000000..58358e4 --- /dev/null +++ b/lib/ui/src/line_editor.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +namespace roro { + +// Single-line UTF-8 text editing. The cursor is a byte index always on a character boundary, +// and the text never exceeds maxBytes (protocol fields are limited in bytes, not characters). +class LineEditor { + public: + explicit LineEditor(size_t maxBytes) : max_(maxBytes) {} + + void setText(const std::string& text); // truncates, cursor to end + const std::string& text() const { return text_; } + size_t cursor() const { return cursor_; } + size_t maxBytes() const { return max_; } + + bool insert(uint32_t codePoint); // false if it would not fit + void backspace(); + void left(); + void right(); + void home() { cursor_ = 0; } + void end() { cursor_ = text_.size(); } + + private: + size_t max_; + std::string text_; + size_t cursor_ = 0; +}; + +} // namespace roro diff --git a/lib/ui/src/list_model.cpp b/lib/ui/src/list_model.cpp new file mode 100644 index 0000000..c929bd8 --- /dev/null +++ b/lib/ui/src/list_model.cpp @@ -0,0 +1,41 @@ +#include "list_model.h" + +namespace roro { + +void ListModel::setCount(int count) { + count_ = count < 0 ? 0 : count; + if (count_ == 0) { + selected_ = -1; + first_ = 0; + return; + } + select(selected_ < 0 ? 0 : selected_); +} + +void ListModel::select(int index) { + if (count_ == 0) return; + selected_ = index < 0 ? 0 : index >= count_ ? count_ - 1 : index; + scrollToSelection(); +} + +void ListModel::up() { + if (count_ == 0) return; + select(selected_ == 0 ? count_ - 1 : selected_ - 1); +} + +void ListModel::down() { + if (count_ == 0) return; + select(selected_ == count_ - 1 ? 0 : selected_ + 1); +} + +void ListModel::pageUp() { select(selected_ - rows_); } +void ListModel::pageDown() { select(selected_ + rows_); } + +void ListModel::scrollToSelection() { + if (selected_ < first_) first_ = selected_; + if (selected_ >= first_ + rows_) first_ = selected_ - rows_ + 1; + int maxFirst = count_ > rows_ ? count_ - rows_ : 0; + if (first_ > maxFirst) first_ = maxFirst; +} + +} // namespace roro diff --git a/lib/ui/src/list_model.h b/lib/ui/src/list_model.h new file mode 100644 index 0000000..65edebf --- /dev/null +++ b/lib/ui/src/list_model.h @@ -0,0 +1,32 @@ +#pragma once + +namespace roro { + +// Selection and scrolling for a vertical list showing `visibleRows` rows at a time. +// Up/Down wrap around; page moves don't. +class ListModel { + public: + explicit ListModel(int visibleRows) : rows_(visibleRows) {} + + void setCount(int count); + int count() const { return count_; } + int selected() const { return selected_; } // -1 when empty + int firstVisible() const { return first_; } + int visibleRows() const { return rows_; } + + void select(int index); + void up(); + void down(); + void pageUp(); + void pageDown(); + + private: + void scrollToSelection(); + + int rows_; + int count_ = 0; + int selected_ = -1; + int first_ = 0; +}; + +} // namespace roro diff --git a/lib/ui/src/text_wrap.cpp b/lib/ui/src/text_wrap.cpp new file mode 100644 index 0000000..a795079 --- /dev/null +++ b/lib/ui/src/text_wrap.cpp @@ -0,0 +1,60 @@ +#include "text_wrap.h" + +#include "utf8.h" + +namespace roro { + +namespace { + +void wrapParagraph(const std::string& para, int maxWidth, const MeasureText& measure, + std::vector& out) { + std::string line; + size_t pos = 0; + while (pos < para.size()) { + size_t space = para.find(' ', pos); + std::string word = para.substr(pos, space == std::string::npos ? std::string::npos : space - pos); + pos = space == std::string::npos ? para.size() : space + 1; + + std::string candidate = line.empty() ? word : line + " " + word; + if (measure(candidate) <= maxWidth) { + line = candidate; + continue; + } + if (!line.empty()) { + out.push_back(line); + line.clear(); + } + // The word alone may still be too wide: cut it character by character. + while (measure(word) > maxWidth) { + size_t cut = 0; + while (true) { + size_t nextCut = utf8::next(word, cut); + if (measure(word.substr(0, nextCut)) > maxWidth) break; + cut = nextCut; + } + if (cut == 0) cut = utf8::next(word, 0); // a single character wider than the line + out.push_back(word.substr(0, cut)); + word = word.substr(cut); + } + line = word; + } + out.push_back(line); +} + +} // namespace + +std::vector wrapText(const std::string& text, int maxWidth, const MeasureText& measure) { + std::vector lines; + if (text.empty()) return lines; + size_t start = 0; + while (true) { + size_t nl = text.find('\n', start); + wrapParagraph(text.substr(start, nl == std::string::npos ? std::string::npos : nl - start), maxWidth, + measure, lines); + if (nl == std::string::npos) break; + start = nl + 1; + } + return lines; +} + +} // namespace roro diff --git a/lib/ui/src/text_wrap.h b/lib/ui/src/text_wrap.h new file mode 100644 index 0000000..f16ef10 --- /dev/null +++ b/lib/ui/src/text_wrap.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +namespace roro { + +using MeasureText = std::function; + +// Splits UTF-8 text into lines no wider than maxWidth: breaks at spaces when possible, inside +// words only when a word alone is too wide, never inside a character. '\n' forces a break. +std::vector wrapText(const std::string& text, int maxWidth, const MeasureText& measure); + +} // namespace roro diff --git a/lib/ui/src/toast_queue.cpp b/lib/ui/src/toast_queue.cpp new file mode 100644 index 0000000..c4cb3c8 --- /dev/null +++ b/lib/ui/src/toast_queue.cpp @@ -0,0 +1,42 @@ +#include "toast_queue.h" + +namespace roro { + +void ToastQueue::push(const std::string& text, NotificationLevel level, uint32_t nowMs) { + current(nowMs); // retire an expired Toast first, so the new one gets its full time + if (queue_.size() == capacity_) { + // Keep the visible one; drop the oldest waiting. + queue_.erase(queue_.begin() + (showing_ && queue_.size() > 1 ? 1 : 0)); + if (!showing_) changed_ = true; + } + queue_.push_back({text, level, nextId_++}); + if (!showing_) { + showing_ = true; + shownAtMs_ = nowMs; + changed_ = true; + } +} + +const Toast* ToastQueue::current(uint32_t nowMs) { + if (!showing_) return nullptr; + // Signed: a caller's clock reading may be a few ms older than the Toast's stamp. + int32_t elapsed = static_cast(nowMs - shownAtMs_); + if (elapsed >= static_cast(durationMs_)) dismiss(shownAtMs_ + durationMs_); + return showing_ ? &queue_.front() : nullptr; +} + +void ToastQueue::dismiss(uint32_t nowMs) { + if (!showing_) return; + queue_.pop_front(); + changed_ = true; + showing_ = !queue_.empty(); + shownAtMs_ = nowMs; +} + +bool ToastQueue::takeChanged() { + bool c = changed_; + changed_ = false; + return c; +} + +} // namespace roro diff --git a/lib/ui/src/toast_queue.h b/lib/ui/src/toast_queue.h new file mode 100644 index 0000000..9b8001c --- /dev/null +++ b/lib/ui/src/toast_queue.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +#include "event.h" + +namespace roro { + +struct Toast { + std::string text; + NotificationLevel level; + uint32_t id; // unique per push, to tell two identical Toasts apart +}; + +// Notifications waiting to be shown as Toasts, one at a time, each for the same duration. +class ToastQueue { + public: + explicit ToastQueue(uint32_t durationMs, size_t capacity = 4) : durationMs_(durationMs), capacity_(capacity) {} + + void push(const std::string& text, NotificationLevel level, uint32_t nowMs); + + // The Toast to show now, or nullptr. Advances the queue as Toasts expire. + const Toast* current(uint32_t nowMs); + void dismiss(uint32_t nowMs); + + // True once after the visible Toast changed (appeared, switched or disappeared). + bool takeChanged(); + + private: + uint32_t durationMs_; + size_t capacity_; + std::deque queue_; + bool showing_ = false; + uint32_t shownAtMs_ = 0; + bool changed_ = false; + uint32_t nextId_ = 1; +}; + +} // namespace roro diff --git a/lib/ui/src/utf8.h b/lib/ui/src/utf8.h new file mode 100644 index 0000000..9e7a752 --- /dev/null +++ b/lib/ui/src/utf8.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include + +namespace roro::utf8 { + +inline bool isContinuation(unsigned char c) { return (c & 0xC0) == 0x80; } + +inline std::string encode(uint32_t cp) { + std::string out; + if (cp < 0x80) { + out += static_cast(cp); + } else if (cp < 0x800) { + out += static_cast(0xC0 | (cp >> 6)); + out += static_cast(0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + out += static_cast(0xE0 | (cp >> 12)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } else { + out += static_cast(0xF0 | (cp >> 18)); + out += static_cast(0x80 | ((cp >> 12) & 0x3F)); + out += static_cast(0x80 | ((cp >> 6) & 0x3F)); + out += static_cast(0x80 | (cp & 0x3F)); + } + return out; +} + +// Byte index of the character start before / after `pos`. +inline size_t previous(const std::string& s, size_t pos) { + if (pos == 0) return 0; + do pos--; + while (pos > 0 && isContinuation(static_cast(s[pos]))); + return pos; +} + +inline size_t next(const std::string& s, size_t pos) { + if (pos >= s.size()) return s.size(); + do pos++; + while (pos < s.size() && isContinuation(static_cast(s[pos]))); + return pos; +} + +// Longest prefix of at most `maxBytes` that doesn't cut a character. +inline size_t clampToBoundary(const std::string& s, size_t maxBytes) { + if (s.size() <= maxBytes) return s.size(); + size_t pos = maxBytes; + while (pos > 0 && isContinuation(static_cast(s[pos]))) pos--; + return pos; +} + +} // namespace roro::utf8 diff --git a/lib/version/src/version.h b/lib/version/src/version.h new file mode 100644 index 0000000..d007f1e --- /dev/null +++ b/lib/version/src/version.h @@ -0,0 +1,14 @@ +#pragma once + +#ifndef RORO_VERSION +#define RORO_VERSION "unknown" +#endif + +namespace roro { + +constexpr const char* kProductName = "roro9stack"; + +// "roro9stack v0.1.0" — used on the boot screen and in About. +inline const char* versionString() { return RORO_VERSION; } + +} // namespace roro diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..fbd0c01 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,28 @@ +; roro9stack — firmware for M5Stack Cardputer ADV + Cap LoRa-1262 +; Build/test/flash through scripts/ci.sh and scripts/flash.sh (Docker), see README. + +[platformio] +default_envs = cardputer-adv + +[env] +extra_scripts = pre:scripts/version.py +test_framework = unity +lib_ldf_mode = deep+ + +[env:cardputer-adv] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.312/platform-espressif32.zip +board = m5stack-stamps3 +framework = arduino +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +build_flags = + -DARDUINO_USB_CDC_ON_BOOT=1 + -DARDUINO_USB_MODE=1 +lib_deps = + m5stack/M5Cardputer @ 1.1.1 +test_ignore = * + +; Host-side unit tests for pure logic (no hardware). +[env:native] +platform = native +build_flags = -std=gnu++17 diff --git a/roro9.sh b/roro9.sh new file mode 100644 index 0000000..a195dd1 --- /dev/null +++ b/roro9.sh @@ -0,0 +1 @@ +claude --resume a3bf56ca-1259-41f1-99f6-9acea510e771 diff --git a/scripts/_docker.sh b/scripts/_docker.sh new file mode 100755 index 0000000..092cbfd --- /dev/null +++ b/scripts/_docker.sh @@ -0,0 +1,14 @@ +# Shared helper: run a command inside the roro9stack build container. +IMAGE=roro9stack-build +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +docker build -q -t "$IMAGE" "$ROOT/docker" >/dev/null + +run_in_container() { + docker run --rm \ + -u "$(id -u):$(id -g)" -e HOME=/tmp \ + -v "$ROOT:/work" \ + -v roro9stack-pio:/pio \ + "${DOCKER_EXTRA[@]}" \ + "$IMAGE" "$@" +} diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..b1fc528 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Local CI: run host unit tests, then build the firmware. +set -euo pipefail +source "$(dirname "$0")/_docker.sh" +DOCKER_EXTRA=() + +run_in_container bash -c 'git config --global --add safe.directory /work && pio test -e native && pio run -e cardputer-adv' diff --git a/scripts/flash.sh b/scripts/flash.sh new file mode 100755 index 0000000..01739ac --- /dev/null +++ b/scripts/flash.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Flash the firmware over USB, then open the serial monitor. +# Usage: scripts/flash.sh [port] (default: the first Espressif device found) +set -euo pipefail +PORT="${1:-$(readlink -f /dev/serial/by-id/*Espressif* 2>/dev/null | head -1)}" +[ -n "$PORT" ] || { echo "No Cardputer found on USB (is it plugged in / attached to the VM?)" >&2; exit 1; } +source "$(dirname "$0")/_docker.sh" +docker rm -f roro9stack-serial >/dev/null 2>&1 || true # a serial log would hold the port +DOCKER_EXTRA=(--device "$PORT" --group-add "$(stat -c %g "$PORT")" -it) + +run_in_container bash -c "git config --global --add safe.directory /work && pio run -e cardputer-adv -t upload --upload-port $PORT && pio device monitor -p $PORT -b 115200" diff --git a/scripts/serial_log.py b/scripts/serial_log.py new file mode 100644 index 0000000..3ee9797 --- /dev/null +++ b/scripts/serial_log.py @@ -0,0 +1,30 @@ +# Reads the Cardputer's serial port, reconnecting when it re-enumerates. Run via serial_log.sh. +import glob, os, sys, time +import serial + +end = time.time() + float(sys.argv[1]) +commands = sys.argv[2:] # sent once after connecting, e.g. "burst" +while time.time() < end: + ports = glob.glob('/dev/serial/by-id/*Espressif*') + if not ports: + time.sleep(1) + continue + try: + s = serial.Serial(os.path.realpath(ports[0]), 115200, timeout=0.2) + print('[connected]', flush=True) + for c in commands: # "sleep:N" waits N seconds between commands + if c.startswith('sleep:'): + time.sleep(float(c[6:])) + continue + time.sleep(1) + s.write((c + '\n').encode()) + commands = [] + buf = b'' + while time.time() < end: + buf += s.read(512) + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + print(line.decode(errors='replace').rstrip(), flush=True) + except Exception as e: + print('[disconnected]', e, flush=True) + time.sleep(1) diff --git a/scripts/serial_log.sh b/scripts/serial_log.sh new file mode 100755 index 0000000..5a0fb6a --- /dev/null +++ b/scripts/serial_log.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Dev helper: record the Cardputer's serial output for N seconds (follows port changes). +# Usage: scripts/serial_log.sh [seconds] [command...] — stop early with: docker rm -f roro9stack-serial +set -euo pipefail +source "$(dirname "$0")/_docker.sh" +docker rm -f roro9stack-serial >/dev/null 2>&1 || true +DOCKER_EXTRA=(--name roro9stack-serial --group-add "$(getent group dialout | cut -d: -f3)" --privileged -v /dev:/dev) +run_in_container /pio/penv/bin/python scripts/serial_log.py "${1:-60}" "${@:2}" diff --git a/scripts/version.py b/scripts/version.py new file mode 100644 index 0000000..39a1e97 --- /dev/null +++ b/scripts/version.py @@ -0,0 +1,13 @@ +# Injects the firmware version from `git describe` (semver tags like v0.1.0). +import subprocess + +Import("env") # noqa: F821 (provided by PlatformIO) + +try: + version = subprocess.check_output( + ["git", "describe", "--tags", "--always", "--dirty"], text=True + ).strip() +except Exception: + version = "unknown" + +env.Append(CPPDEFINES=[("RORO_VERSION", '\\"%s\\"' % version)]) # noqa: F821 diff --git a/src/apps/demo_app.cpp b/src/apps/demo_app.cpp new file mode 100644 index 0000000..4a46f8c --- /dev/null +++ b/src/apps/demo_app.cpp @@ -0,0 +1,110 @@ +#include "demo_app.h" + +#include "ui/widgets.h" + +namespace roro { + +namespace { +const char* const kMenu[] = {"Text view", "Line editor", "Dialog", "Toast: info", "Toast: warning", + "Toast: burst of 5"}; +const char* const kText = + "Bienvenue sur roro9stack ! Ce texte teste le retour \xC3\xA0 la ligne automatique et les " + "accents : \xC3\xA9t\xC3\xA9, \xC3\xA0 bient\xC3\xB4t, gar\xC3\xA7on, No\xC3\xABl, " + "ma\xC3\xAEtre, o\xC3\xB9.\n\n" + "Use ; and . to scroll, ` to go back. Fn+` always returns to the Launcher.\n\n" + "Unbreakablewordthatislongerthanawholelineofthescreen to test hard breaks."; +} // namespace + +void DemoApp::onEnter() { + page_ = Page::Menu; + dialog_.reset(); + menu_.setCount(sizeof(kMenu) / sizeof(kMenu[0])); +} + +void DemoApp::notify(const char* text, NotificationLevel level) { + bus_.publish(Event::withText(EventType::Notification, text, static_cast(level))); +} + +bool DemoApp::onKey(const KeyEvent& e) { + requestRedraw(); + if (dialog_) { + dialog_->onKey(e); + int r = dialog_->result(); + if (r != DialogModel::kPending) { + notify(r == DialogModel::kCancelled ? "Dialog cancelled" : ("Chose " + dialog_->buttons()[r]).c_str(), + NotificationLevel::Info); + dialog_.reset(); + } + return true; + } + switch (page_) { + case Page::Menu: return onMenuKey(e); + case Page::Text: + if (e.key == Key::Up && textScroll_ > 0) textScroll_--; + else if (e.key == Key::Down && textScroll_ < textLineCount_ - 1) textScroll_++; + else if (e.key == Key::Back) page_ = Page::Menu; + return true; + case Page::Editor: + switch (e.key) { + case Key::Char: editor_.insert(e.ch); break; + case Key::Delete: editor_.backspace(); break; + case Key::Left: editor_.left(); break; + case Key::Right: editor_.right(); break; + case Key::Up: editor_.home(); break; + case Key::Down: editor_.end(); break; + case Key::Select: + if (!editor_.text().empty()) notify(editor_.text().c_str(), NotificationLevel::Message); + break; + case Key::Back: page_ = Page::Menu; break; + default: break; + } + return true; + } + return false; +} + +bool DemoApp::onMenuKey(const KeyEvent& e) { + switch (e.key) { + case Key::Up: menu_.up(); return true; + case Key::Down: menu_.down(); return true; + case Key::Select: break; + default: return false; // Back leaves the App + } + switch (menu_.selected()) { + case 0: page_ = Page::Text; textScroll_ = 0; break; + case 1: page_ = Page::Editor; break; + case 2: dialog_.reset(new DialogModel({"Cancel", "Maybe", "OK"})); break; + case 3: notify("Hello from the demo", NotificationLevel::Info); break; + case 4: notify("Something needs attention", NotificationLevel::Warning); break; + case 5: + for (int i = 1; i <= 5; i++) notify(("Burst " + std::to_string(i)).c_str(), NotificationLevel::Info); + break; + } + return true; +} + +void DemoApp::draw(Canvas& c) { + const auto& area = theme::kContent; + switch (page_) { + case Page::Menu: + widgets::list(c, menu_, area, [](int i) { return std::string(kMenu[i]); }); + break; + case Page::Text: { + auto lines = wrapText(kText, area.w - 10, widgets::bodyMeasure(c)); + textLineCount_ = static_cast(lines.size()); + widgets::textLines(c, lines, textScroll_, area); + break; + } + case Page::Editor: + c.setTextColor(theme::kMuted); + c.drawString("Type (opt ' e = \xC3\xA9). Enter: toast", 4, area.y + 4); + widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); + c.setTextColor(theme::kMuted); + c.drawString((std::to_string(editor_.text().size()) + "/39 bytes").c_str(), 4, area.y + 44); + break; + } + if (dialog_) + widgets::dialog(c, "Dialog", "Left/Right or Tab to choose, Enter to confirm, ` to cancel.", *dialog_); +} + +} // namespace roro diff --git a/src/apps/demo_app.h b/src/apps/demo_app.h new file mode 100644 index 0000000..8f2e39e --- /dev/null +++ b/src/apps/demo_app.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include + +#include "app.h" +#include "dialog_model.h" +#include "event_bus.h" +#include "line_editor.h" +#include "list_model.h" +#include "ui/theme.h" + +namespace roro { + +// Exercises every widget: text view, line editor, dialog and Toasts. +class DemoApp : public App { + public: + explicit DemoApp(EventBus& bus) : bus_(bus) {} + void onEnter() override; + bool onKey(const KeyEvent& e) override; + bool textEntryActive() const override { return page_ == Page::Editor && !dialog_; } + void draw(Canvas& c) override; + + private: + enum class Page { Menu, Text, Editor }; + bool onMenuKey(const KeyEvent& e); + void notify(const char* text, NotificationLevel level); + + EventBus& bus_; + Page page_ = Page::Menu; + ListModel menu_{theme::kContent.h / theme::kLineHeight}; + int textScroll_ = 0; + int textLineCount_ = 0; + LineEditor editor_{39}; + std::unique_ptr dialog_; +}; + +} // namespace roro diff --git a/src/apps/launcher_app.cpp b/src/apps/launcher_app.cpp new file mode 100644 index 0000000..10af6da --- /dev/null +++ b/src/apps/launcher_app.cpp @@ -0,0 +1,27 @@ +#include "launcher_app.h" + +#include "ui/widgets.h" + +namespace roro { + +void LauncherApp::onEnter() { list_.setCount(static_cast(manager_->visibleApps().size())); } + +bool LauncherApp::onKey(const KeyEvent& e) { + switch (e.key) { + case Key::Up: list_.up(); break; + case Key::Down: list_.down(); break; + case Key::Select: + if (list_.selected() >= 0) manager_->open(manager_->visibleApps()[list_.selected()]->id); + return true; + default: return false; + } + requestRedraw(); + return true; +} + +void LauncherApp::draw(Canvas& c) { + auto apps = manager_->visibleApps(); + widgets::list(c, list_, theme::kContent, [&](int i) { return std::string(apps[i]->title); }); +} + +} // namespace roro diff --git a/src/apps/launcher_app.h b/src/apps/launcher_app.h new file mode 100644 index 0000000..e75ef4f --- /dev/null +++ b/src/apps/launcher_app.h @@ -0,0 +1,23 @@ +#pragma once + +#include "app.h" +#include "app_manager.h" +#include "list_model.h" +#include "ui/theme.h" + +namespace roro { + +// Home screen: lists the visible Apps; Select opens one. +class LauncherApp : public App { + public: + void setManager(AppManager& manager) { manager_ = &manager; } + void onEnter() override; + bool onKey(const KeyEvent& e) override; + void draw(Canvas& c) override; + + private: + AppManager* manager_ = nullptr; + ListModel list_{theme::kContent.h / theme::kLineHeight}; +}; + +} // namespace roro diff --git a/src/apps/settings_app.cpp b/src/apps/settings_app.cpp new file mode 100644 index 0000000..73efcd7 --- /dev/null +++ b/src/apps/settings_app.cpp @@ -0,0 +1,210 @@ +#include "settings_app.h" + +#include + +#include "platform/identity.h" +#include "ui/fonts.h" +#include "ui/widgets.h" +#include "version.h" + +namespace roro { + +using Kind = SettingsMenu::Kind; +using Row = SettingsMenu::Row; + +void SettingsApp::onEnter() { + page_ = Page::Menu; + eraseDialog_.reset(); + list_.setCount(menu_.count()); +} + +void SettingsApp::warn(const std::string& text) { + d_.bus.publish(Event::withText(EventType::Notification, text.c_str(), static_cast(NotificationLevel::Warning))); +} + +bool SettingsApp::onKey(const KeyEvent& e) { + requestRedraw(); + switch (page_) { + case Page::Menu: return onMenuKey(e); + case Page::Text: return onTextKey(e); + case Page::Choice: return onChoiceKey(e); + case Page::Storage: return onStorageKey(e); + case Page::About: return onAboutKey(e); + } + return false; +} + +bool SettingsApp::onMenuKey(const KeyEvent& e) { + int i = list_.selected(); + switch (e.key) { + case Key::Up: list_.up(); return true; + case Key::Down: list_.down(); return true; + case Key::Left: + case Key::Right: + if (menu_.kind(i) == Kind::Slider) menu_.adjust(i, e.key == Key::Right ? 1 : -1); + if (menu_.kind(i) == Kind::Toggle) menu_.toggle(i); + return true; + case Key::Select: break; + default: return false; // Back leaves Settings + } + editingRow_ = i; + switch (menu_.kind(i)) { + case Kind::Text: + editor_ = LineEditor(menu_.maxBytes(i)); + editor_.setText(menu_.value(i)); + page_ = Page::Text; + break; + case Kind::Choice: + choices_.setCount(static_cast(menu_.choices(i).size())); + choices_.select(menu_.currentChoice(i) < 0 ? 0 : menu_.currentChoice(i)); + page_ = Page::Choice; + break; + case Kind::Toggle: menu_.toggle(i); break; + case Kind::Slider: break; + case Kind::Page: page_ = menu_.row(i) == Row::Storage ? Page::Storage : Page::About; break; + } + return true; +} + +bool SettingsApp::onTextKey(const KeyEvent& e) { + switch (e.key) { + case Key::Char: editor_.insert(e.ch); break; + case Key::Delete: editor_.backspace(); break; + case Key::Left: editor_.left(); break; + case Key::Right: editor_.right(); break; + case Key::Select: { + std::string error = menu_.setText(editingRow_, editor_.text()); + if (error.empty()) page_ = Page::Menu; + else warn(error); + break; + } + case Key::Back: page_ = Page::Menu; break; // cancel + default: break; + } + return true; +} + +bool SettingsApp::onChoiceKey(const KeyEvent& e) { + switch (e.key) { + case Key::Up: choices_.up(); break; + case Key::Down: choices_.down(); break; + case Key::Select: { + std::string error = menu_.choose(editingRow_, choices_.selected()); + if (error.empty()) page_ = Page::Menu; + else warn(error); + break; + } + case Key::Back: page_ = Page::Menu; break; + default: break; + } + return true; +} + +bool SettingsApp::onStorageKey(const KeyEvent& e) { + if (eraseDialog_) { + eraseDialog_->onKey(e); + if (eraseDialog_->result() == 1 && !d_.storage.requestFormat()) warn("A format is already running"); + if (eraseDialog_->result() != DialogModel::kPending) eraseDialog_.reset(); + return true; + } + if (e.key == Key::Select) { + if (d_.storage.state().present) eraseDialog_.reset(new DialogModel({"Cancel", "Erase"})); + else warn("No SD card"); + } + if (e.key == Key::Back) page_ = Page::Menu; + return true; +} + +bool SettingsApp::onAboutKey(const KeyEvent& e) { + if (e.key == Key::Back) page_ = Page::Menu; + if (e.key == Key::Char && e.ch == 'w') d_.apps.open("demo"); // hidden widget demo + return true; +} + +void SettingsApp::update(uint32_t nowMs) { + // Live values on Storage and About. + if ((page_ == Page::Storage || page_ == Page::About) && nowMs - lastRefreshMs_ >= 1000) { + lastRefreshMs_ = nowMs; + requestRedraw(); + } +} + +std::vector SettingsApp::storageLines() const { + auto s = d_.storage.state(); + std::vector lines; + char buf[64]; + if (d_.storage.formatting()) { + lines.push_back("Erasing the SD card..."); + } else if (!s.present) { + lines.push_back("No SD card inserted."); + } else { + snprintf(buf, sizeof(buf), "SD card: %u MB, %d%% used", static_cast(s.totalBytes >> 20), s.usedPercent); + lines.push_back(buf); + lines.push_back(s.logsAllowed ? "Logs: recording" : "Logs: paused (over 90%)"); + lines.push_back(s.capturesAllowed ? "Captures: allowed" : "Captures: stopped (card full)"); + } + lines.push_back(""); + lines.push_back("Enter: erase SD card"); + return lines; +} + +std::vector SettingsApp::aboutLines() const { + std::vector lines; + char buf[64]; + snprintf(buf, sizeof(buf), "%s %s", kProductName, versionString()); + lines.push_back(buf); + lines.push_back("Node " + identity::nodeId()); + const auto& b = d_.battery.estimator(); + snprintf(buf, sizeof(buf), "Battery %d%% (%d mV)", b.percent(), b.millivolts()); + lines.push_back(buf); + snprintf(buf, sizeof(buf), "Memory %u KB free (min %u KB)", ESP.getFreeHeap() / 1024, ESP.getMinFreeHeap() / 1024); + lines.push_back(buf); + uint32_t up = millis() / 1000; + snprintf(buf, sizeof(buf), "Uptime %luh %02lum %02lus", (unsigned long)(up / 3600), (unsigned long)(up / 60 % 60), + (unsigned long)(up % 60)); + lines.push_back(buf); + lines.push_back("Clock " + d_.clock.displayTime()); + lines.push_back("License GPL-3.0"); + return lines; +} + +void SettingsApp::draw(Canvas& c) { + const auto& area = theme::kContent; + switch (page_) { + case Page::Menu: + widgets::list( + c, list_, area, [this](int i) { return menu_.label(i); }, + [this](int i) { + auto kind = menu_.kind(i); + if (kind == Kind::Page) return std::string(">"); + if (kind == Kind::Slider) return "< " + menu_.value(i) + " >"; + return menu_.value(i); + }); + break; + case Page::Text: + c.setFont(&fonts::body); + c.setTextColor(theme::kMuted); + c.drawString(menu_.label(editingRow_).c_str(), 4, area.y + 4); + widgets::lineEditor(c, editor_, {4, area.y + 22, area.w - 8, 0}); + c.drawString(("Enter: save `: cancel " + std::to_string(editor_.text().size()) + "/" + + std::to_string(editor_.maxBytes()) + " bytes") + .c_str(), + 4, area.y + 44); + break; + case Page::Choice: { + auto options = menu_.choices(editingRow_); + int current = menu_.currentChoice(editingRow_); + widgets::list(c, choices_, area, [&](int i) { return (i == current ? "* " : " ") + options[i]; }); + break; + } + case Page::Storage: + widgets::textLines(c, storageLines(), 0, area); + if (eraseDialog_) + widgets::dialog(c, "Erase SD card?", "Everything on the card is deleted. It becomes one FAT32 partition.", + *eraseDialog_); + break; + case Page::About: widgets::textLines(c, aboutLines(), 0, area); break; + } +} + +} // namespace roro diff --git a/src/apps/settings_app.h b/src/apps/settings_app.h new file mode 100644 index 0000000..4332d18 --- /dev/null +++ b/src/apps/settings_app.h @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include + +#include "app.h" +#include "app_manager.h" +#include "dialog_model.h" +#include "event_bus.h" +#include "line_editor.h" +#include "list_model.h" +#include "services/battery_service.h" +#include "services/clock_service.h" +#include "services/storage_service.h" +#include "settings_menu.h" +#include "ui/theme.h" + +namespace roro { + +struct SettingsAppDeps { + Settings& settings; + EventBus& bus; + AppManager& apps; + BatteryService& battery; + StorageService& storage; + ClockService& clock; +}; + +// Settings: every user-facing setting, plus the Storage and About pages. +class SettingsApp : public App { + public: + explicit SettingsApp(const SettingsAppDeps& deps) : d_(deps), menu_(deps.settings) {} + void onEnter() override; + bool onKey(const KeyEvent& e) override; + void update(uint32_t nowMs) override; + bool textEntryActive() const override { return page_ == Page::Text; } + void draw(Canvas& c) override; + + private: + enum class Page { Menu, Text, Choice, Storage, About }; + + bool onMenuKey(const KeyEvent& e); + bool onTextKey(const KeyEvent& e); + bool onChoiceKey(const KeyEvent& e); + bool onStorageKey(const KeyEvent& e); + bool onAboutKey(const KeyEvent& e); + void warn(const std::string& text); + std::vector storageLines() const; + std::vector aboutLines() const; + + SettingsAppDeps d_; + SettingsMenu menu_; + Page page_ = Page::Menu; + ListModel list_{theme::kContent.h / theme::kLineHeight}; + ListModel choices_{theme::kContent.h / theme::kLineHeight}; + LineEditor editor_{39}; + std::unique_ptr eraseDialog_; + int editingRow_ = 0; + uint32_t lastRefreshMs_ = 0; +}; + +} // namespace roro diff --git a/src/apps/setup_app.cpp b/src/apps/setup_app.cpp new file mode 100644 index 0000000..f967fa9 --- /dev/null +++ b/src/apps/setup_app.cpp @@ -0,0 +1,99 @@ +#include "setup_app.h" + +#include "platform/identity.h" +#include "ui/fonts.h" +#include "ui/widgets.h" + +namespace roro { + +using Step = SetupWizard::Step; + +SetupApp::SetupApp(Settings& settings, AppManager& apps) + : apps_(apps), wizard_(settings, identity::defaultLongName(), identity::defaultShortName()) {} + +bool SetupApp::onKey(const KeyEvent& e) { + requestRedraw(); + bool choosing = wizard_.choiceCount() > 0; + bool typing = wizard_.step() == Step::LongName || wizard_.step() == Step::ShortName; + switch (e.key) { + case Key::Select: + if (choosing) wizard_.selectChoice(choices_.selected()); + if (wizard_.next()) { + if (wizard_.finished()) { + apps_.endModal(); + return true; + } + choices_.setCount(wizard_.choiceCount()); + if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice()); + } + break; + case Key::Back: + case Key::Home: + wizard_.back(); + choices_.setCount(wizard_.choiceCount()); + if (wizard_.choiceCount()) choices_.select(wizard_.selectedChoice()); + break; + case Key::Up: if (choosing) choices_.up(); break; + case Key::Down: if (choosing) choices_.down(); break; + case Key::Left: if (typing) wizard_.editor().left(); break; + case Key::Right: if (typing) wizard_.editor().right(); break; + case Key::Delete: if (typing) wizard_.editor().backspace(); break; + case Key::Char: if (typing) wizard_.editor().insert(e.ch); break; + default: break; + } + return true; +} + +void SetupApp::draw(Canvas& c) { + const auto& area = theme::kContent; + auto heading = [&](const char* text) { + c.setFont(&fonts::bold); + c.setTextColor(theme::kAccent); + c.drawString(text, 4, area.y + 3); + c.setFont(&fonts::body); + }; + auto note = [&](const std::string& text, int y, uint16_t color = theme::kMuted) { + auto lines = wrapText(text, area.w - 8, widgets::bodyMeasure(c)); + c.setTextColor(color); + for (size_t i = 0; i < lines.size(); i++) c.drawString(lines[i].c_str(), 4, y + i * theme::kLineHeight); + }; + + switch (wizard_.step()) { + case Step::Welcome: + heading("Welcome to roro9stack"); + note("Let's set up this device: your names on the mesh, your radio region and your timezone.", + area.y + 22, theme::kText); + note("Enter: continue", area.y + area.h - 14); + break; + case Step::LongName: + heading("Long name (1/4)"); + note("Shown to other mesh users. Up to 39 bytes.", area.y + 20); + widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0}); + break; + case Step::ShortName: + heading("Short name (2/4)"); + note("Up to 4 characters, shown where space is tight.", area.y + 20); + widgets::lineEditor(c, wizard_.editor(), {4, area.y + 50, area.w - 8, 0}); + break; + case Step::Region: + heading("Radio region (3/4)"); + note("The radio only transmits once you confirm your region.", area.y + 18); + widgets::list(c, choices_, {0, area.y + 48, area.w, 4 * theme::kLineHeight}, + [this](int i) { return wizard_.choiceLabel(i); }); + note("Enter: I confirm I'm in this region", area.y + area.h - 14); + break; + case Step::Timezone: + heading("Timezone (4/4)"); + widgets::list(c, choices_, {0, area.y + 20, area.w, 4 * theme::kLineHeight}, + [this](int i) { return wizard_.choiceLabel(i); }); + break; + case Step::Done: + heading("All set"); + note("You can change all of this later in Settings.", area.y + 22, theme::kText); + note("Enter: start `: go back", area.y + area.h - 14); + break; + } + if (!wizard_.error().empty()) note(wizard_.error(), area.y + area.h - 14, theme::kWarning); +} + +} // namespace roro diff --git a/src/apps/setup_app.h b/src/apps/setup_app.h new file mode 100644 index 0000000..2a9921a --- /dev/null +++ b/src/apps/setup_app.h @@ -0,0 +1,27 @@ +#pragma once + +#include "app.h" +#include "app_manager.h" +#include "list_model.h" +#include "setup_wizard.h" +#include "ui/theme.h" + +namespace roro { + +// First-boot setup, run as a modal App until finished. +class SetupApp : public App { + public: + SetupApp(Settings& settings, AppManager& apps); + bool onKey(const KeyEvent& e) override; + bool textEntryActive() const override { + return wizard_.step() == SetupWizard::Step::LongName || wizard_.step() == SetupWizard::Step::ShortName; + } + void draw(Canvas& c) override; + + private: + AppManager& apps_; + SetupWizard wizard_; + ListModel choices_{4}; +}; + +} // namespace roro diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0c67698 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,170 @@ +// roro9stack firmware entry point: wires Services, Apps and the UI loop together. +#include + +#include "app_manager.h" +#include "apps/demo_app.h" +#include "apps/launcher_app.h" +#include "apps/settings_app.h" +#include "apps/setup_app.h" +#include "event_bus.h" +#include "key_mapper.h" +#include "platform/nvs_store.h" +#include "service_manager.h" +#include "services/battery_service.h" +#include "services/clock_service.h" +#include "services/power_service.h" +#include "services/storage_service.h" +#include "settings.h" +#include "ui/notifier.h" +#include "ui/screen.h" +#include "version.h" + +using namespace roro; + +static EventBus bus; +static NvsStore nvs; +static Settings settings(nvs, bus); +static ServiceManager services; +static KeyMapper keyMapper; +static Screen screen; + +// Constructed in setup(), after the hardware and Settings are ready. +static BatteryService* battery; +static StorageService* storage; +static PowerService* power; +static ClockService* clockService; +static Notifier* notifier; +static LauncherApp launcher; +static AppManager* apps; + +static RawKeys readKeys() { + auto state = M5Cardputer.Keyboard.keysState(); + RawKeys raw; + raw.chars.assign(state.word.begin(), state.word.end()); + raw.fn = state.fn; + raw.opt = state.opt; + raw.ctrl = state.ctrl; + raw.alt = state.alt; + raw.shift = state.shift; + raw.enter = state.enter; + raw.del = state.del; + raw.tab = state.tab; + return raw; +} + +static StatusInfo currentStatus() { + StatusInfo s; + s.title = kProductName; + if (apps->foregroundTitle()) s.title = apps->foregroundTitle(); + if (battery->estimator().hasReading()) s.batteryPercent = battery->estimator().percent(); + s.clock = clockService->displayTime(); + auto storageState = storage->state(); + s.sdPresent = storageState.present; + s.sdLevel = storageState.level; + s.compose = keyMapper.pendingCompose(); + return s; +} + +void setup() { + auto cfg = M5.config(); + M5Cardputer.begin(cfg, true); + M5Cardputer.Display.setRotation(1); + Serial.begin(115200); + + nvs.begin(); + settings.load(); + + battery = new BatteryService(bus); + storage = new StorageService(bus); + power = new PowerService(settings); + clockService = new ClockService(settings, bus); + notifier = new Notifier(bus, settings); + notifier->onShow = [](uint32_t now, uint32_t until) { power->onNotification(now, until); }; + services.add(*power); + services.add(*clockService); + services.add(*battery); + services.add(*storage); + + apps = new AppManager(launcher); + launcher.setManager(*apps); + apps->registerApp({"settings", "Settings", false, + new SettingsApp({settings, bus, *apps, *battery, *storage, *clockService})}); + apps->registerApp({"demo", "Widget demo", true, new DemoApp(bus)}); + apps->registerApp({"setup", "Setup", true, new SetupApp(settings, *apps)}); + + bus.subscribe(EventType::Notification, [](const Event& e) { Serial.printf("notification: %s\n", e.text); }); + + if (!screen.begin()) Serial.println("frame buffer allocation failed"); + services.startAll(millis()); + apps->begin(); + if (!settings.getBool(Setting::SetupDone)) apps->openModal("setup"); + Serial.printf("%s %s ready, free heap %u\n", kProductName, versionString(), ESP.getFreeHeap()); +} + +// Dev aid: serial commands to drive the UI without the keyboard. +static void serialCommands() { + static String line; + while (Serial.available()) { + char c = Serial.read(); + if (c != '\n') { + line += c; + continue; + } + line.trim(); + if (line == "burst") + for (int i = 1; i <= 5; i++) + bus.publish(Event::withText(EventType::Notification, ("Burst " + String(i)).c_str(), 0)); + if (line.startsWith("key ")) { // key up|down|left|right|select|back|home + String k = line.substring(4); + Key key = k == "up" ? Key::Up : k == "down" ? Key::Down : k == "left" ? Key::Left + : k == "right" ? Key::Right : k == "back" ? Key::Back : k == "home" ? Key::Home : Key::Select; + if (!power->onKey(millis())) apps->handleKey(KeyEvent::of(key)); + } + if (line == "sound off") settings.setBool(Setting::Sound, false); + if (line == "sound on") settings.setBool(Setting::Sound, true); + if (line == "short") { + settings.setInt(Setting::DimTimeoutS, 5); + settings.setInt(Setting::OffTimeoutS, 10); + } + if (line == "normal") { + settings.setInt(Setting::OffTimeoutS, 60); + settings.setInt(Setting::DimTimeoutS, 30); + } + line = ""; + } +} + +void loop() { + static StatusInfo lastStatus; + static uint32_t lastLog = 0; + uint32_t now = millis(); + + serialCommands(); + M5Cardputer.update(); + if (M5Cardputer.Keyboard.isChange()) { + bool swallow = M5Cardputer.Keyboard.isPressed() && power->onKey(now); // only woke the screen + keyMapper.setTextEntry(apps->foreground().textEntryActive()); + auto events = keyMapper.update(readKeys()); // always, so held keys are tracked + if (!swallow) + for (auto& e : events) apps->handleKey(e); + } + + services.tick(now); + bus.dispatch(); + notifier->update(now); + apps->update(now); + + if (now - lastLog > 10000) { + lastLog = now; + Serial.printf("status: heap %u min %u\n", ESP.getFreeHeap(), ESP.getMinFreeHeap()); + } + + if (power->screen() == ScreenState::Off) return; + StatusInfo status = currentStatus(); + const Toast* toast = notifier->toasts().current(now); + bool toastChanged = notifier->toasts().takeChanged(); + if (apps->takeRedraw() || toastChanged || status != lastStatus) { + lastStatus = status; + screen.render(*apps, status, toast); + } +} diff --git a/src/platform/identity.h b/src/platform/identity.h new file mode 100644 index 0000000..e40a783 --- /dev/null +++ b/src/platform/identity.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +namespace roro::identity { + +// Node number as Meshtastic derives it: the last four bytes of the chip's MAC address. +inline uint32_t nodeNum() { + uint64_t mac = ESP.getEfuseMac(); // byte 0 of the MAC is the lowest byte + auto byte = [mac](int i) { return static_cast((mac >> (8 * i)) & 0xFF); }; + return (byte(2) << 24) | (byte(3) << 16) | (byte(4) << 8) | byte(5); +} + +// "!2f762fa4" +inline std::string nodeId() { + char buf[12]; + std::snprintf(buf, sizeof(buf), "!%08lx", static_cast(nodeNum())); + return buf; +} + +// Suggested names for the setup wizard: "roro9stack 2fa4" and "2fa4". +inline std::string defaultShortName() { + char buf[8]; + std::snprintf(buf, sizeof(buf), "%04lx", static_cast(nodeNum() & 0xFFFF)); + return buf; +} +inline std::string defaultLongName() { return "roro9stack " + defaultShortName(); } + +} // namespace roro::identity diff --git a/src/platform/nvs_store.h b/src/platform/nvs_store.h new file mode 100644 index 0000000..14cc4df --- /dev/null +++ b/src/platform/nvs_store.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "key_value_store.h" + +namespace roro { + +// Settings persistence in the ESP32's internal flash (NVS). +class NvsStore : public KeyValueStore { + public: + void begin() { prefs_.begin("roro", false); } + + bool getInt(const char* key, int32_t& out) override { + if (!prefs_.isKey(key)) return false; + out = prefs_.getInt(key); + return true; + } + bool getString(const char* key, std::string& out) override { + if (!prefs_.isKey(key)) return false; + out = prefs_.getString(key).c_str(); + return true; + } + void putInt(const char* key, int32_t value) override { prefs_.putInt(key, value); } + void putString(const char* key, const std::string& value) override { + prefs_.putString(key, value.c_str()); + } + + private: + Preferences prefs_; +}; + +} // namespace roro diff --git a/src/platform/pins.h b/src/platform/pins.h new file mode 100644 index 0000000..9468003 --- /dev/null +++ b/src/platform/pins.h @@ -0,0 +1,17 @@ +#pragma once + +// Cardputer ADV + Cap LoRa-1262 pin map (see docs.m5stack.com). +namespace roro::pins { + +constexpr int kBatteryAdc = 10; // through a 1:2 divider + +constexpr int kButtonG0 = 0; + +// SPI bus shared by the microSD card and the LoRa radio. +constexpr int kSpiSck = 40; +constexpr int kSpiMiso = 39; +constexpr int kSpiMosi = 14; +constexpr int kSdCs = 12; +constexpr int kLoraCs = 5; + +} // namespace roro::pins diff --git a/src/platform/shared_spi.h b/src/platform/shared_spi.h new file mode 100644 index 0000000..6db3a04 --- /dev/null +++ b/src/platform/shared_spi.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +#include "pins.h" + +namespace roro { + +// The SPI bus used by the microSD card and (from M3) the LoRa radio. The display has its own bus. +// Every user must wrap transfers in beginTransaction/endTransaction, which also locks the bus. +inline SPIClass& sharedSpi() { + static SPIClass spi(HSPI); + static bool begun = false; + if (!begun) { + // Keep the radio deselected so it never answers SD card traffic. + pinMode(pins::kLoraCs, OUTPUT); + digitalWrite(pins::kLoraCs, HIGH); + spi.begin(pins::kSpiSck, pins::kSpiMiso, pins::kSpiMosi, -1); + begun = true; + } + return spi; +} + +} // namespace roro diff --git a/src/services/battery_service.cpp b/src/services/battery_service.cpp new file mode 100644 index 0000000..84ba67a --- /dev/null +++ b/src/services/battery_service.cpp @@ -0,0 +1,19 @@ +#include "battery_service.h" + +#include + +#include "platform/pins.h" + +namespace roro { + +void BatteryService::tick(uint32_t) { + lastRaw_ = analogReadMilliVolts(pins::kBatteryAdc) * 2; + estimator_.addSample(lastRaw_); + int percent = estimator_.percent(); + if (percent != reportedPercent_) { + reportedPercent_ = percent; + bus_.publish(Event::withValues(EventType::BatteryChanged, percent, estimator_.millivolts())); + } +} + +} // namespace roro diff --git a/src/services/battery_service.h b/src/services/battery_service.h new file mode 100644 index 0000000..7867d62 --- /dev/null +++ b/src/services/battery_service.h @@ -0,0 +1,26 @@ +#pragma once + +#include "battery_estimator.h" +#include "event_bus.h" +#include "service.h" + +namespace roro { + +class BatteryService : public Service { + public: + explicit BatteryService(EventBus& bus) : bus_(bus) {} + const char* name() const override { return "battery"; } + uint32_t tickIntervalMs() const override { return 2000; } + void tick(uint32_t nowMs) override; + + const BatteryEstimator& estimator() const { return estimator_; } + int lastRawMillivolts() const { return lastRaw_; } + + private: + EventBus& bus_; + BatteryEstimator estimator_; + int lastRaw_ = 0; + int reportedPercent_ = -1; +}; + +} // namespace roro diff --git a/src/services/clock_service.h b/src/services/clock_service.h new file mode 100644 index 0000000..a9d6222 --- /dev/null +++ b/src/services/clock_service.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include "clock_model.h" +#include "event_bus.h" +#include "service.h" +#include "settings.h" + +namespace roro { + +// Holds wall-clock time. Sources (GNSS in M2, NTP in M1, mesh in M4) call set(). +class ClockService : public Service { + public: + ClockService(const Settings& settings, EventBus& bus) : settings_(settings) { + bus.subscribe(EventType::SettingChanged, [this](const Event& e) { + if (e.a == static_cast(Setting::Timezone)) applyTimezone(); + }); + } + const char* name() const override { return "clock"; } + void start() override { applyTimezone(); } + + bool set(int64_t utcSeconds, TimeSource source) { return model_.set(utcSeconds, source, millis()); } + const ClockModel& model() const { return model_; } + + // "14:05" once set; otherwise "--:--". + std::string displayTime() const { + return model_.isSet() ? ClockModel::formatLocalTime(model_.utcNow(millis())) : "--:--"; + } + + private: + void applyTimezone() { ClockModel::applyTimezone(settings_.getString(Setting::Timezone).c_str()); } + + const Settings& settings_; + ClockModel model_; +}; + +} // namespace roro diff --git a/src/services/power_service.cpp b/src/services/power_service.cpp new file mode 100644 index 0000000..bf969db --- /dev/null +++ b/src/services/power_service.cpp @@ -0,0 +1,60 @@ +#include "power_service.h" + +#include +#include + +#include "platform/pins.h" + +namespace roro { + +PowerService::PowerService(const Settings& settings) + : settings_(settings), + policy_(settings.getInt(Setting::DimTimeoutS) * 1000, settings.getInt(Setting::OffTimeoutS) * 1000) {} + +void PowerService::start() { + pinMode(pins::kButtonG0, INPUT_PULLUP); + policy_.activity(millis()); + applyScreen(ScreenState::On); +} + +void PowerService::tick(uint32_t nowMs) { + policy_.setTimeouts(settings_.getInt(Setting::DimTimeoutS) * 1000, + settings_.getInt(Setting::OffTimeoutS) * 1000); + applyScreen(policy_.update(nowMs)); + + bool pressed = digitalRead(pins::kButtonG0) == LOW; + if (button_.update(pressed, nowMs) == ButtonAction::LongPress) powerOff(); +} + +void PowerService::applyScreen(ScreenState state) { + auto& display = M5Cardputer.Display; + int percent = settings_.getInt(Setting::Brightness); + int target = state == ScreenState::On ? percent : state == ScreenState::Dimmed ? percent / 4 : 0; + if (state == applied_ && target == appliedBrightness_) return; + + if (state == ScreenState::Off) + display.sleep(); + else if (applied_ == ScreenState::Off) + display.wakeup(); + display.setBrightness(target * 255 / 100); + applied_ = state; + appliedBrightness_ = target; +} + +void PowerService::powerOff() { + auto& display = M5Cardputer.Display; + display.wakeup(); + display.setBrightness(settings_.getInt(Setting::Brightness) * 255 / 100); + display.fillScreen(TFT_BLACK); + display.setTextColor(TFT_WHITE); + display.drawCenterString("Powering off", display.width() / 2, display.height() / 2 - 8); + // G0 wakes the device on a low level, so wait for release or it would wake immediately. + while (digitalRead(pins::kButtonG0) == LOW) delay(10); + delay(300); + display.sleep(); + display.setBrightness(0); + esp_sleep_enable_ext0_wakeup(static_cast(pins::kButtonG0), 0); + esp_deep_sleep_start(); +} + +} // namespace roro diff --git a/src/services/power_service.h b/src/services/power_service.h new file mode 100644 index 0000000..6af6f3e --- /dev/null +++ b/src/services/power_service.h @@ -0,0 +1,38 @@ +#pragma once + +#include "power_policy.h" +#include "service.h" +#include "settings.h" + +namespace roro { + +// Screen dimming/off from user activity and G0 handling. A long G0 press powers off (deep sleep, +// woken by G0); Services stop with it. +class PowerService : public Service { + public: + explicit PowerService(const Settings& settings); + const char* name() const override { return "power"; } + uint32_t tickIntervalMs() const override { return 50; } + void start() override; + void tick(uint32_t nowMs) override; + + // Call for every key press before routing it. Returns true if the key must not reach the App. + bool onKey(uint32_t nowMs) { return policy_.activity(nowMs); } + + // A Toast is showing until untilMs: light an Off screen (dimmed) so it can be read. + void onNotification(uint32_t nowMs, uint32_t untilMs) { policy_.notify(nowMs, untilMs); } + + ScreenState screen() const { return applied_; } + + private: + void applyScreen(ScreenState state); + void powerOff(); + + const Settings& settings_; + PowerPolicy policy_; + PowerButton button_{2000}; + ScreenState applied_ = ScreenState::On; + int appliedBrightness_ = -1; +}; + +} // namespace roro diff --git a/src/services/storage_service.cpp b/src/services/storage_service.cpp new file mode 100644 index 0000000..08ed21c --- /dev/null +++ b/src/services/storage_service.cpp @@ -0,0 +1,99 @@ +#include "storage_service.h" + +#include +#include +#include + +#include + +#include "platform/pins.h" +#include "platform/shared_spi.h" + +namespace roro { + +void StorageService::start() { + if (task_) return; + lock_ = xSemaphoreCreateMutex(); + xTaskCreate(taskEntry, "storage", 4096, this, 1, &task_); +} + +void StorageService::stop() { + if (!task_) return; + vTaskDelete(task_); + task_ = nullptr; + if (mounted_) SD.end(); + mounted_ = false; +} + +StorageState StorageService::state() const { + xSemaphoreTake(lock_, portMAX_DELAY); + StorageState copy = monitor_.state(); + xSemaphoreGive(lock_); + return copy; +} + +void StorageService::taskEntry(void* self) { + auto* service = static_cast(self); + for (;;) { + if (service->formatRequested_) { + service->format(); + service->formatRequested_ = false; + } + service->poll(); + // Sleep until the next poll, or until a format request wakes us early. + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(kPollMs)); + } +} + +bool StorageService::requestFormat() { + if (formatRequested_ || !task_) return false; + formatRequested_ = true; + xTaskNotifyGive(task_); + return true; +} + +void StorageService::format() { + if (mounted_) SD.end(); + mounted_ = false; + + bool ok = false; + uint8_t pdrv = sdcard_init(pins::kSdCs, &sharedSpi(), 20000000); + if (pdrv != 0xFF) { + constexpr size_t kWorkSize = 4096; // FF_MAX_SS + std::unique_ptr work(new uint8_t[kWorkSize]); + LBA_t partitions[] = {100, 0}; // one partition, 100 % of the card + char drive[3] = {static_cast('0' + pdrv), ':', 0}; + MKFS_PARM options = {FM_FAT32, 0, 0, 0, 0}; + ok = f_fdisk(pdrv, partitions, work.get()) == FR_OK && + f_mkfs(drive, &options, work.get(), kWorkSize) == FR_OK; + sdcard_uninit(pdrv); + } + bus_.publish(Event::withText(EventType::Notification, ok ? "SD card formatted" : "SD card format failed", + static_cast(ok ? NotificationLevel::Info : NotificationLevel::Warning))); +} + +bool StorageService::mount() { + return SD.begin(pins::kSdCs, sharedSpi(), 20000000, "/sd", 5, false); +} + +void StorageService::poll() { + if (mounted_) { + // Detect removal: the root can no longer be opened. + File root = SD.open("/"); + if (!root) { + SD.end(); + mounted_ = false; + } + } + if (!mounted_) mounted_ = mount(); + + bool present = mounted_; + uint64_t total = present ? SD.totalBytes() : 0; + uint64_t used = present ? SD.usedBytes() : 0; + + xSemaphoreTake(lock_, portMAX_DELAY); + monitor_.update(present, total, used); + xSemaphoreGive(lock_); +} + +} // namespace roro diff --git a/src/services/storage_service.h b/src/services/storage_service.h new file mode 100644 index 0000000..b3caa9a --- /dev/null +++ b/src/services/storage_service.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +#include "service.h" +#include "storage_monitor.h" + +namespace roro { + +// Mounts the microSD card (retrying while absent) and feeds its usage to the StorageMonitor. +// Runs on its own task: a failed mount blocks for seconds and must never stall the UI. +class StorageService : public Service { + public: + explicit StorageService(EventBus& bus) : monitor_(bus), bus_(bus) {} + const char* name() const override { return "storage"; } + void start() override; + void stop() override; + + StorageState state() const; + + // Erases the whole card: one partition spanning the card, formatted FAT32. Runs on the storage + // task; the result arrives as a Notification. Returns false if a format is already pending. + bool requestFormat(); + bool formatting() const { return formatRequested_; } + + private: + static constexpr uint32_t kPollMs = 15000; + static void taskEntry(void* self); + void poll(); + bool mount(); + void format(); + + StorageMonitor monitor_; + bool mounted_ = false; + EventBus& bus_; + volatile bool formatRequested_ = false; + TaskHandle_t task_ = nullptr; + mutable SemaphoreHandle_t lock_ = nullptr; +}; + +} // namespace roro diff --git a/src/ui/canvas.h b/src/ui/canvas.h new file mode 100644 index 0000000..df2d17c --- /dev/null +++ b/src/ui/canvas.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace roro { + +// The off-screen frame Apps draw into (full M5GFX drawing API). It's pushed to the display in one +// transfer, so the screen never shows a half-drawn frame. +class Canvas : public M5Canvas { + public: + using M5Canvas::M5Canvas; +}; + +} // namespace roro diff --git a/src/ui/font_data.cpp b/src/ui/font_data.cpp new file mode 100644 index 0000000..c9d2ecb --- /dev/null +++ b/src/ui/font_data.cpp @@ -0,0 +1,224 @@ +// Vendored from u8g2 (https://github.com/olikraus/u8g2, csrc/u8g2_fonts.c). +// X11 misc-fixed fonts, public domain. Latin-1 coverage (code points 32-255). +#include "fonts.h" + +namespace roro::fontdata { + +/* + Fontname: -Misc-Fixed-Medium-R-SemiCondensed--13-120-75-75-C-60-ISO10646-1 + Copyright: Public domain font. Share and enjoy. + Glyphs: 191/4121 + BBX Build Mode: 0 +*/ +const uint8_t _6x13_tf[2161] = + "\277\0\3\2\3\4\3\5\4\5\15\0\376\11\376\12\377\1_\2\304\10T \5\0n\7!\7IC" + "\307!\11\42\7\233ZGb\11#\16=FO)I\6\245\62(\245$\1$\17MBW\66(Q" + "\222mI\224\14Z\4%\15MBO\244t\314\212I\213\224\0&\16EBO\226DIV\211\242)" + "R\2'\6\31[\307\0(\13\333>W\22%Q\267(\13)\14\333>G\26eQ\227(\211\0*" + "\12-RW\245\262\64E\0+\12-JW\30\15R\30\1,\7\233>\217R\2-\6\15R\307 " + ".\10\233>O\262$\0/\13MBg-\314\212Y\30\2\60\13MBW\226\324\274%\265\10\61\13" + "MBW&%aO\203\0\62\13MB\317\222ia\326q\20\63\14MB\307 f\265\65\324\222\5" + "\64\17MB_\230II\224\224\222A\13\23\0\65\14MB\307\261b\12C-Y\0\66\15MB\317" + "\222\211\341\220d\266d\1\67\14MB\307 fa\26fa\15\70\15MB\317\222\331\222%\263%\13" + "\0\71\15MB\317\222\331\222!\14\265d\1:\13\303>O\262\344H\262$\0;\12\303>O\262\344" + "\210R\2<\10MBg\326\265\3=\11%J\307\240c\203\0>\11MBG\332\255#\0\77\14M" + "B\317\222iaV\314\241\10@\15MB\317\222\231\226DI,\351\20A\14MBW\226\324l\303\220" + "\331\2B\20MB\307\20U\242$J\246J\224D\203\2C\13MB\317\222\211\275%\13\0D\22M" + "B\307\20U\242$J\242$J\242$\32\24\0E\13MB\307\261\70$aq\20F\13MB\307\261" + "\70$a#\0G\14MB\317\222\211M\233\226,\0H\13MBG\346\66\14\231[\0I\10\313B" + "\307\22\365eJ\13MB\327\26\366\22E\22\0K\15MBGfJJZ\22U\62-L\11MB" + "G\330\217\203\0M\15MBG\246-K\242$\232[\0N\15MBG\66MJ\242$\322M\13O" + "\12MB\317\222\371-Y\0P\14MB\307\220d\266A\11\33\1Q\12U>\317\222\371\245\262\6R" + "\15MB\307\220d\266A)U\62-S\14MB\317\222\211\351\32j\311\2T\11MB\307 \205\375" + "\4U\11MBG\346o\311\2V\15MBG\346\226\224\222(\311\302\10W\15MBG\346%Q\22" + "%Q\272\0X\16MBG\246%\245$\253DIM\13Y\14MBG\246%\245$\13;\1Z\14" + "MB\307 faV\314\302A[\11\333>\307\20\365\247\1\134\13MBG\230\206i\61\15\3]\11" + "\333>\307\324\237\206\0^\10\35ZW\226\324\2_\6\15>\307 `\6\22cG\24a\13\65B\317" + "\232\14\232\244(\1b\14MBGX\34\222\314mP\0c\12\65B\317\222\211\265d\1d\13MB" + "g\313\240\271%C\0e\14\65B\317\222\15C\230%\13\0f\14MB\227T\11\263!\12\333\0g" + "\14E:\317\222\331\222!\324\222\5h\12MBG\330b\322\334\2i\10\303BO(u\31j\13T" + ":_\254\265IR\242\0k\15MBG\330\224\224\264$\252d\1l\10\313B\207\324/\3m\15\65" + "B\207\322\242$J\242$Z\0n\11\65BGb\322\334\2o\12\65B\317\222\271%\13\0p\14E" + ":\307\220d\266A\11\213\0q\12E:\317\240\331\222!lr\11\65BGb\22\33\1s\14\65B" + "\317\222%j\222%\13\0t\14EBO\230\15Q\330\24)\0u\11\65BG\346IQ\2v\13\65" + "BGfKJI\26\1w\13\65BGfI\224D\351\2x\13\65BG\226\324\302,\251\5y\14" + "E:G\346\244(\241\226,\0z\11\65B\307\240u\34\4{\13]>\237\24\66\251a\253\0|\6" + "IC\307\3}\13]>\207\32\266Ja\223\6~\11\35ZO\244$R\2\240\5\0n\7\241\10I" + "CG\62\14\1\242\16EFW\266T\224(\211\222\312\26\1\243\15MB\227T\11\263-,)\12\0" + "\244\14\65FG\226LI\224DK\26\245\17MBG\246%\245$\31\244h\220\302\10\246\10IC\307" + "\220\14\1\247\16\324B\217\22\211J$%\242\224(\0\250\7\223bG\242\4\251\17MF\317\222)\311" + "!Z\226DK\26\0\252\14=J\317\232\14Z\62\244\203\0\253\13\65FW\322S\222%Y\22\254\7" + "\35J\307 \26\255\6\213R\307\0\256\16MF\317\222\15\311!\31\224[\262\0\257\6\15b\307 \260" + "\11\244V\217\22I\211\2\261\13=FW\30\15R\230\17\2\262\10+VOS\222\14\263\7+VO" + "\267\5\264\6\22c\217\2\265\12E:G\346iP\304\20\266\22MB\317\60$C\62$C\222(Q" + "\22%Q\22\267\6\12S\207\0\270\6\22;\217\2\271\10+VO\42U\6\272\12=J\317\222\331\222" + "y\20\273\14\65FG\222%Y\22%=\1\274\14UBO&\326\346LJ\242\61\275\14UBO&" + "\326\326,\11\263l\276\17UBO\226di\224dQ&%\321\30\277\14MBW\16\205Y\61\323\222" + "\5\300\16UBO\232CYR\323\206!\323\2\301\15UB_\35\312\222\232\66\14\231\26\302\15UB" + "\227T\317\222\232\66\14\231\26\303\16UBW\322\216dIM\33\206L\13\304\17UBO)\311\221," + "\251i\303\220i\1\305\17UBW\226da\226\324\264a\310\264\0\306\21MBO\262DI\224D\211" + "\62%Q\22%\3\307\13]:\317\222\211\275%[\15\310\15UBO\232\17c\70$a\70\10\311\14" + "UB_}\30\303!\11\303A\312\15UB\227T\35\306pH\302p\20\313\16UBO)\211\207\61" + "\34\222\60\34\4\314\12\323BG\26.Q\227\1\315\12\323BW\22.Q\227\1\316\13\324B\217\22\205" + "S\326i\1\317\12\323BG\242dK\324e\320\21MB\307\20U\242$\32\222R\22%\321\240\0\321" + "\15UBW\322\234i\223\222H\233\26\322\14UBO\232#K\346-Y\0\323\13UB_\35Y\62" + "o\311\2\324\13UB\227T^\62o\311\2\325\13UBW\322\276d\336\222\5\326\14UBO)\311" + "\227\314[\262\0\327\12-FG\226\324*\265\0\330\20]>g\262D\227DI\224d\232\222%\4\331" + "\13UBO\232g\276%\13\0\332\12UB_=\363-Y\0\333\13UB\227T\315|K\26\0\334" + "\13UBO)\211\63\337\222\5\335\13UB_=\323\222Z\330\4\336\15MBG\70$\231mP\302" + "\42\0\337\16MB\217T\211\222R\22U\62\213\2\340\15MBO\232#k\62h\222\242\4\341\14M" + "B_\35Y\223A\223\24%\342\14MB\227T^\223A\223\24%\343\15MBW\322\276&\203&)" + "J\0\344\15MBO)\311\327d\320$E\11\345\15UB\227T\322\327d\320$E\11\346\13\65B" + "\317\226$K)\351\2\347\13E:\317\222\211\265d\253\1\350\16MBO\232#K\66\14a\226,\0" + "\351\15MB_\35Y\262a\10\263d\1\352\15MB\227T^\262a\10\263d\1\353\16MBO)" + "\311\227l\30\302,Y\0\354\11\313BG\26J]\6\355\11\313BW\22J]\6\356\12\314B\217\22" + "\205Z\247\5\357\12\313BG\242dR\227\1\360\15UBO-S\243%sK\26\0\361\13MBW" + "\322\234\230\64\267\0\362\14MBO\232#K\346\226,\0\363\13MB_\35Y\62\267d\1\364\13M" + "B\227T^\62\267d\1\365\13MBW\322\276dn\311\2\366\14MBO)\311\227\314-Y\0\367" + "\12=FW\230\17z\30\1\370\16E>g\262DK\242$S\262\204\0\371\12MBO\232g\236\24" + "%\372\12MB_=\363\244(\1\373\13MB\227T\315<)J\0\374\13MBO)\211\63O\212" + "\22\375\15]:_=sR\224PK\26\0\376\15U:GX\61i\332\244(a\10\377\16]:O" + ")\211\63'E\11\265d\1\0\0\0\4\377\377\0"; + +/* + Fontname: -Misc-Fixed-Bold-R-SemiCondensed--13-120-75-75-C-60-ISO10646-1 + Copyright: Public domain font. Share and enjoy. + Glyphs: 191/1282 + BBX Build Mode: 0 +*/ +const uint8_t _6x13B_tf[2247] = + "\277\0\3\3\3\4\3\5\4\6\15\0\376\11\376\12\377\1e\2\300\10\252 \5\0n\7!\7JC" + "\307\223\0\42\10\35Z\207\204E\0#\14>F\17\211b\241XH\24\0$\13>F\227\214Bc\241" + "\211\0%\17NB\217\344\20!\312\24)\207\210\4\0&\14>B\217\214D\223\35\42\242\2'\6\32" + "[\207\1(\14\334>\227D$\21\351&\222\11)\15\334>\207L$\23\351\42\222\210\0*\13.J" + "\207HB\261PD\2+\12.J\227Pd\22\212\0,\10\234>\317D\11\0-\6\16R\207\1." + "\10\234>\217\204\42\1/\14NB\247\232P\246(\23\12\1\60\14NB\227\214\42\342IB\23\1\61" + "\12NB\227lD\324\223\1\62\13NB\17ED\22\312t\64\63\15NB\207Q\246F\25\222$\24" + "\0\64\16NB\237P\66\42E\264\330\204\22\0\65\16NB\307!(\254L\204B\222\204\2\66\16N" + "B\327H&\24V&$\222\204\2\67\15NB\207Q&\224\11eB\65\0\70\16NB\17E\304$" + "\241\210\230$\24\0\71\15NB\17E\304\62)\252\211F\0:\14\304B\217\204\42\207I(\22\0;" + "\13\304>\217\204\42\207M\224\0<\10NB\247LW\35=\7&J\207\35j>\11NB\207T\67" + "\35\1\77\16NB\17ED\22\312\24\345\60\21\0@\14NB\17E\304r\70X\244\5A\14NB" + "\227\214\42b:\214\230\4B\15NBGI\242\27\222D\227\13\0C\13NB\17ED\324\223\204\2" + "D\13NBGI\242\377\313\5\0E\13NB\307!\250X\21*\32F\13NB\307!\250X\21j" + "\4G\15NB\17ED\324R\42I(\0H\13NB\207\210\323a\304I\0I\11\314B\7E\244" + "/\4J\13NB\27Q\217$\11\5\0K\16NB\207\210EB\232\221$J$\1L\10NB\207" + "P\77\32M\13NB\207\210tx\10q\22N\15NB\207\250R\71\34$M$\1O\13NB\17" + "E\304O\22\12\0P\13NBGE\304t\21j\4Q\13V>\17E\304\247C\204*R\15NB" + "GE\304t!I\224H\2S\16NB\17ED\224R\205$\11\5\0T\11NB\207I\250\237\0" + "U\12NB\207\210\77I(\0V\14NB\207\210\223\204\304&\24\1W\12NB\207\210\323\341\223\0" + "X\16NB\207\210$!\321d$\212\210$Y\14NB\207\210$!\321\204:\1Z\14NB\207Q" + "&\224)\312\204\6[\10\334>\207I\177\42\134\14NB\207P*\224*J\205\2]\10\334>\7I" + "\177\62^\10\36Z\227\214\42\22_\6\16>\207\1`\7\223b\207H\0a\12\66B\17Ur\242L" + "\24b\16NB\207P\213dB\42U(\22\0c\13\66B\17EDT\222P\0d\13NB\247\26" + "\11\245\304\62Qe\13\66B\17Et\30J)\0f\14NB\327H\242(+\11\265\1g\15F:" + "\317E\323HJ\21I(\0h\13NB\207P\213dB\342$i\12\314B\217H:\322\205\0j\14" + "]:\237\372L\33\205\42\231\0k\15NB\207P\213\204\64#I\224\4l\10\314B\307H\277\20m" + "\11\66BG\345\360P\22n\12\66B\207D\62!q\22o\12\66B\17E\304IB\1p\15F:" + "\207D\62!U(\212\212\0q\13F:\217\204R\242L\24\65r\12\66B\207D\62!j\4s\15" + "\66B\17E$\231JD\22\12\0t\14FB\217PV\22j\21M\0u\11\66B\207\210\227\211\2" + "v\13\66B\207\210IB\242\211\0w\14\66B\207\210t\70XB\21\0x\14\66B\207HB\23\312" + "(\42\1y\14F:\207\210\313D\221$\241\0z\10\66B\207MG\3{\15]>\327D\246(\31" + "\212d\212\3|\7JC\307\3\1}\16]>\307P\246$\234\10e*#\0~\10\35Z\217\344\24" + "\1\240\5\0n\7\241\7RC\7\351p\242\20FF_\214\42\211LB\222\220$\205\30\2\243\15N" + "B\327H\242(+\11\225,\0\244\14\66F\207\350\20\11e\71\204\4\245\16NB\207\210$!QL" + "\42\223P\4\246\10RC\307!x\10\247\17VB\17ED\245\210H\22*IB\1\250\7\26^\207" + "\210$\251\20NB\17E\64\221PJ\224\211d$\241\0\252\12>JG\365 ;\204\15\253\21>F" + "\227$\42\211HB\221\230$&\211I\2\254\10&J\307\241\232\0\255\6\214R\7\1\256\20NB\17" + "E\64\251D&\25\225\10IB\1\257\7\25^\307a\0\260\12&V\17ED\222P\0\261\15NB" + "\227Pt(\11\345\220C\1\262\12\64R\307$\42\222(\21\263\12\64R\307$\242\66\251\0\264\7\223" + "b\17\5\0\265\12F:\207\210\247\203\64\12\266\20NB\317\341\20i\211LB\222\220$$\11\267\6" + "\22S\7\1\270\7\223:\17\5\0\271\11\64R\217d\244\205\0\272\11=N\317\304f\31\27\273\22>" + "FGD\26\221Ed\221PD\22\221DD\0\274\15VB\217l\250\351\66\222\204\210\2\275\15VB" + "\217l\250H\231\10e\212\4\276\21VB\307,\42\223\212\42\22\212l$\11\21\5\277\17VB\227P" + "\16\23\312\24E$\11\5\0\300\16VB\217T\16\223QD\244\303\210$\301\16VB\237:LF\21" + "\221\16#\222\0\302\17VB\327H\42\207\310(\42\322aD\22\303\17VB\227$E\16\222QD\244" + "\303\210$\304\17VB\207\210$\207\310(\42\322aD\22\305\17VB\227,\24\242\311(\42\322aD" + "\22\306\21NB\217E\24\21ED\221\11\211\24\21E\10\307\14^:\17ED\324\223\204\246\6\310\16" + "VB\217T\16\71\4\205\25\241\320\0\311\15VB\237:\344\20\24V\204B\3\312\16VB\327H\42" + ">\4\205\25\241\320\0\313\16VB\207\210$>\4\205\25\241\320\0\314\12\324B\207LJ\21\351B\315" + "\12\324B\227DJ\21\351B\316\13UB\317D\231$\323\211\0\317\14VB\207\210$\247\11u\243\0" + "\320\14NBGI\242KE_.\0\321\16VB\227$E.\252T\16\223&\1\322\15VB\217T" + "\16\242\210x\222P\0\323\14VB\237:\210\42\342IB\1\324\15VB\327H\42\247\210x\222P\0" + "\325\16VB\227$E\16\241\210x\222P\0\326\15VB\207\210$\247\210x\222P\0\327\13.F\207" + "HB\223QD\2\330\16N>o\204\42i\252T$\224(\0\331\14VB\217T\16\21\361IB\1" + "\332\14VB\237:D\304'\11\5\0\333\14VB\327H\42\26\361IB\1\334\13VB\217\316\42>" + "I(\0\335\16VB\237:D$\11\205hBM\0\336\14NB\207\260\42b\272\10\25\1\337\15N" + ">\17ED\272\210\230.Q\0\340\15NB\217T\16\242JN\224\211\2\341\14NB\237:\210*\71" + "Q&\12\342\15NB\327H\42\247JN\224\211\2\343\16NB\227$E\16\241JN\224\211\2\344\13" + "NB\217\356T\311\211\62Q\345\17VB\327,\22\233C\250\222\23e\242\0\346\17\66B\17M\22\241" + "DD\21ID\22\1\347\14F:\17EDT\222\320\324\0\350\16NB\217T\16\242\210\16C)\5" + "\0\351\15NB\237:\210\42:\14\245\24\0\352\16NB\327H\42\247\210\16C)\5\0\353\14NB" + "\217\356\24\321a(\245\0\354\12\314B\207L:\322\205\0\355\12\314B\227D:\322\205\0\356\12MB" + "\317Dy\246\23\1\357\12MB\207\204\42\236\351D\360\20VB\207D\64\42\205D\24\21'\11\5\0" + "\361\15NB\227$E.\221LH\234\4\362\15NB\217T\16\242\210\70I(\0\363\14NB\237:" + "\210\42\342$\241\0\364\15NB\327H\42\247\210\70I(\0\365\16NB\227$E\16\241\210\70I(" + "\0\366\15NB\207\210$\247\210\70I(\0\367\14>F\227P\16\261C\204\42\0\370\16N>o\204" + "\42\242\224*$\11%\12\371\13NB\217T\16\21\361\62Q\372\13NB\237:D\304\313D\1\373\13" + "NB\327H\42\26\361\62Q\374\13NB\207\210$\26\361\62Q\375\16^:\237:D\304e\242H\222" + "P\0\376\16V:\207PE\62!U(\212\212\0\377\17^:\207\210$\26q\231(\222$\24\0\0" + "\0\0\4\377\377\0"; + +/* + Fontname: -Misc-Fixed-Medium-R-Normal--8-80-75-75-C-50-ISO10646-1 + Copyright: Public domain font. Share and enjoy. + Glyphs: 191/1426 + BBX Build Mode: 0 +*/ +const uint8_t _5x8_tf[1715] = + "\277\0\2\2\3\4\3\4\4\5\10\0\377\6\377\6\0\1\32\2\61\6\226 \5\0~\3!\7\61c" + "\63R\0\42\7\233n\223\254\0#\15=bW\246\64T\65T\231\22\0$\12=b\233W\275S\332" + "\21%\10\253f\23Sg\0&\12\10\263b\223\313T\2\77\11\263b" + "\327L\31&\0@\14E^+\243\134I%YC\5A\11\64b\247\242\34S\6B\12\64b\263\342" + "HQ\216\4C\11\64b\247\242.\223\2D\11\64b\263\242s$\0E\11\64b\63\364\312y\4F" + "\11\64b\63\364\312\65\0G\12\64b\247\242N\63)\0H\11\64b\23\345\230f\0I\7\263b\263" + "bkJ\11\64b\67sUF\0K\11\64b\23U\222\251\63L\10\64b\223\273G\0M\11\64b" + "\23\307\21\315\0N\11\64b\23\327Xg\0O\11\64b\247\242\63)\0P\12\64b\263\242\34)g" + "\0Q\11<^\247\242\134n\24R\12\64b\263\242\34)\312\0S\12\64b\247b\312\250L\12T\10" + "\263b\263b\27\0U\10\64b\23=\223\2V\11\64b\23\235I*\0W\11\64b\23\315q\304\0" + "X\12\64b\23e\222*\312\0Y\13\65b\223u\252\63\312(\2Z\11\64b\63rl\217\0[\7" + "\263b\63bs\134\12\64b\223\63\312(\243\34]\7\263b\63\233#^\6\223r\327\0_\6\14^" + "\63\2`\6\222r\23\3a\10$b\67\242L\3b\12\64b\223\363\212r$\0c\7\243b\67\263" + "\0d\11\64b_\215(\323\0e\10$b\247\322\310\12f\11\64b[\225\63G\0g\11,^\247" + "b\332I\1h\11\64b\223\363\212f\0i\10\263b\227\221\254\6j\11\273^\233a\251*\0k\11" + "\64b\223\313\221\242\14l\7\263b#\273\6m\11%b\243Z*\251\2n\7$b\263\242\31o\10" + "$b\247\242L\12p\11,^\263\342H\71\3q\10,^\67b\332\5r\10$b\223\222\235\1s" + "\7\243b\67\362\2t\12\64b\227\343\314)&\0u\7$b\23\315\64v\7\243b\223\254\12w\11" + "%b\223UR]\0x\10$b\23\223T\61y\12,^\23e\32\61)\0z\10$b\63b\71" + "\2{\13 + +namespace roro { + +namespace fontdata { +extern const uint8_t _6x13_tf[]; +extern const uint8_t _6x13B_tf[]; +extern const uint8_t _5x8_tf[]; +} // namespace fontdata + +// Latin-1 bitmap fonts (accented letters included). +namespace fonts { +inline const lgfx::U8g2font body(fontdata::_6x13_tf); // 6x13 +inline const lgfx::U8g2font bold(fontdata::_6x13B_tf); // 6x13 bold +inline const lgfx::U8g2font small(fontdata::_5x8_tf); // 5x8, Status Bar +} // namespace fonts + +} // namespace roro diff --git a/src/ui/notifier.cpp b/src/ui/notifier.cpp new file mode 100644 index 0000000..49a46be --- /dev/null +++ b/src/ui/notifier.cpp @@ -0,0 +1,42 @@ +#include "notifier.h" + +#include + +namespace roro { + +namespace { +constexpr int kLedPin = 21; // WS2812, powered with the backlight rail (G38) +} + +Notifier::Notifier(EventBus& bus, const Settings& settings) : settings_(settings) { + bus.subscribe(EventType::Notification, [this](const Event& e) { + toasts_.push(e.text, static_cast(e.a), millis()); + }); +} + +void Notifier::alert(NotificationLevel level, uint32_t nowMs) { + if (!settings_.getBool(Setting::Sound)) return; + M5Cardputer.Speaker.tone(level == NotificationLevel::Warning ? 880 : 1760, 60); + switch (level) { + case NotificationLevel::Warning: rgbLedWrite(kLedPin, 40, 16, 0); break; + case NotificationLevel::Message: rgbLedWrite(kLedPin, 0, 40, 0); break; + default: rgbLedWrite(kLedPin, 0, 16, 40); break; + } + ledOn_ = true; + ledOffAtMs_ = nowMs + 150; +} + +void Notifier::update(uint32_t nowMs) { + const Toast* shown = toasts_.current(nowMs); + if (shown && shown->id != alertedId_) { + alertedId_ = shown->id; + alert(shown->level, nowMs); + if (onShow) onShow(nowMs, nowMs + kToastMs); + } + if (ledOn_ && static_cast(nowMs - ledOffAtMs_) >= 0) { + rgbLedWrite(kLedPin, 0, 0, 0); + ledOn_ = false; + } +} + +} // namespace roro diff --git a/src/ui/notifier.h b/src/ui/notifier.h new file mode 100644 index 0000000..2f22e19 --- /dev/null +++ b/src/ui/notifier.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include "event_bus.h" +#include "settings.h" +#include "toast_queue.h" + +namespace roro { + +// Presents Notifications as Toasts. Each Toast gets a short beep and LED flash (when Sound is on) +// as it appears, so a burst of Notifications alerts once per Toast, not all at once. +class Notifier { + public: + Notifier(EventBus& bus, const Settings& settings); + void update(uint32_t nowMs); // alerts for a newly shown Toast, ends the LED flash + ToastQueue& toasts() { return toasts_; } + + // Called when a Toast appears, with the time it will disappear (e.g. to light the screen). + std::function onShow; + + private: + void alert(NotificationLevel level, uint32_t nowMs); + + const Settings& settings_; + static constexpr uint32_t kToastMs = 3000; + ToastQueue toasts_{kToastMs, 8}; + uint32_t ledOffAtMs_ = 0; + bool ledOn_ = false; + uint32_t alertedId_ = 0; +}; + +} // namespace roro diff --git a/src/ui/screen.cpp b/src/ui/screen.cpp new file mode 100644 index 0000000..e45fd42 --- /dev/null +++ b/src/ui/screen.cpp @@ -0,0 +1,24 @@ +#include "screen.h" + +#include + +namespace roro { + +bool Screen::begin() { + canvas_.setColorDepth(16); + canvas_.setPsram(false); + return canvas_.createSprite(theme::kWidth, theme::kHeight) != nullptr; +} + +void Screen::render(AppManager& apps, const StatusInfo& status, const Toast* toast) { + canvas_.fillSprite(theme::kBackground); + const auto& area = theme::kContent; + canvas_.setClipRect(area.x, area.y, area.w, area.h); + apps.foreground().draw(canvas_); + canvas_.clearClipRect(); + widgets::statusBar(canvas_, status); + if (toast) widgets::toast(canvas_, *toast); + canvas_.pushSprite(&M5Cardputer.Display, 0, 0); +} + +} // namespace roro diff --git a/src/ui/screen.h b/src/ui/screen.h new file mode 100644 index 0000000..be9010d --- /dev/null +++ b/src/ui/screen.h @@ -0,0 +1,20 @@ +#pragma once + +#include "app_manager.h" +#include "canvas.h" +#include "widgets.h" + +namespace roro { + +// Composes the frame (Status Bar, foreground App, Toast) off-screen and pushes it to the display. +class Screen { + public: + bool begin(); // allocates the frame buffer (~64 KB) + void render(AppManager& apps, const StatusInfo& status, const Toast* toast); + Canvas& canvas() { return canvas_; } + + private: + Canvas canvas_; +}; + +} // namespace roro diff --git a/src/ui/theme.h b/src/ui/theme.h new file mode 100644 index 0000000..52a28da --- /dev/null +++ b/src/ui/theme.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace roro::theme { + +// RGB565 +constexpr uint16_t kBackground = 0x0000; +constexpr uint16_t kText = 0xFFFF; +constexpr uint16_t kMuted = 0x8410; // grey +constexpr uint16_t kAccent = 0x2CBF; // teal-blue +constexpr uint16_t kSelection = 0x1A6F; // darker accent behind selected rows +constexpr uint16_t kBar = 0x2104; // Status Bar background +constexpr uint16_t kWarning = 0xFC60; // orange +constexpr uint16_t kMessage = 0x3666; // green + +constexpr int kWidth = 240; +constexpr int kHeight = 135; +constexpr int kStatusBarHeight = 11; +constexpr int kLineHeight = 13; // body font + +struct Rect { + int x, y, w, h; +}; + +// Where Apps draw: everything below the Status Bar. +constexpr Rect kContent{0, kStatusBarHeight + 1, kWidth, kHeight - kStatusBarHeight - 1}; + +} // namespace roro::theme diff --git a/src/ui/widgets.cpp b/src/ui/widgets.cpp new file mode 100644 index 0000000..6d5c631 --- /dev/null +++ b/src/ui/widgets.cpp @@ -0,0 +1,143 @@ +#include "widgets.h" + +#include "fonts.h" + +namespace roro::widgets { + +using namespace theme; + +MeasureText bodyMeasure(Canvas& canvas) { + return [&canvas](const std::string& s) { + canvas.setFont(&fonts::body); + return static_cast(canvas.textWidth(s.c_str())); + }; +} + +void statusBar(Canvas& c, const StatusInfo& info) { + c.fillRect(0, 0, kWidth, kStatusBarHeight, kBar); + c.setFont(&fonts::small); + c.setTextDatum(top_left); + c.setTextColor(kText); + c.drawString(info.title.c_str(), 3, 2); + + // Right side, laid out right to left. + int x = kWidth - 3; + auto right = [&](const std::string& text, uint16_t color) { + c.setTextColor(color); + c.setTextDatum(top_right); + c.drawString(text.c_str(), x, 2); + x -= c.textWidth(text.c_str()) + 6; + }; + right(info.clock, kText); + if (info.batteryPercent >= 0) + right(std::to_string(info.batteryPercent) + "%", info.batteryPercent <= 15 ? kWarning : kText); + if (info.sdPresent) right("SD", info.sdLevel >= 80 ? kWarning : kMuted); + if (info.compose) right(std::string("opt ") + (info.compose == '*' ? "" : std::string(1, info.compose)), kAccent); + c.setTextDatum(top_left); +} + +void list(Canvas& c, const ListModel& model, Rect area, const std::function& label, + const std::function& value) { + c.setFont(&fonts::body); + c.setTextDatum(top_left); + int rows = model.visibleRows(); + for (int row = 0; row < rows; row++) { + int i = model.firstVisible() + row; + if (i >= model.count()) break; + int y = area.y + row * kLineHeight; + bool selected = i == model.selected(); + if (selected) c.fillRect(area.x, y, area.w - 4, kLineHeight, kSelection); + c.setTextColor(kText); + c.drawString(label(i).c_str(), area.x + 4, y + 1); + if (value) { + c.setTextColor(selected ? kText : kMuted); + c.setTextDatum(top_right); + c.drawString(value(i).c_str(), area.x + area.w - 8, y + 1); + c.setTextDatum(top_left); + } + } + // Scrollbar + if (model.count() > rows) { + int barH = area.h * rows / model.count(); + int barY = area.y + (area.h - barH) * model.firstVisible() / (model.count() - rows); + c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted); + } +} + +void textLines(Canvas& c, const std::vector& lines, int firstLine, Rect area) { + c.setFont(&fonts::body); + c.setTextDatum(top_left); + c.setTextColor(kText); + int rows = area.h / kLineHeight; + for (int row = 0; row < rows && firstLine + row < static_cast(lines.size()); row++) + c.drawString(lines[firstLine + row].c_str(), area.x + 4, area.y + row * kLineHeight + 1); + int total = static_cast(lines.size()); + if (total > rows) { + int barH = area.h * rows / total; + int barY = area.y + (area.h - barH) * firstLine / (total - rows); + c.fillRect(area.x + area.w - 2, barY, 2, barH, kMuted); + } +} + +void lineEditor(Canvas& c, const LineEditor& editor, Rect area) { + c.setFont(&fonts::body); + c.setTextDatum(top_left); + c.drawRect(area.x, area.y, area.w, kLineHeight + 4, kAccent); + + const std::string& text = editor.text(); + int cursorX = c.textWidth(text.substr(0, editor.cursor()).c_str()); + int visible = area.w - 8; + int scroll = cursorX > visible ? cursorX - visible : 0; + + c.setClipRect(area.x + 2, area.y + 1, area.w - 4, kLineHeight + 2); + c.setTextColor(kText); + c.drawString(text.c_str(), area.x + 3 - scroll, area.y + 3); + c.fillRect(area.x + 3 + cursorX - scroll, area.y + 2, 1, kLineHeight, kAccent); + c.clearClipRect(); +} + +void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model) { + Rect box{16, 26, kWidth - 32, 92}; + c.fillRect(box.x, box.y, box.w, box.h, kBackground); + c.drawRect(box.x, box.y, box.w, box.h, kAccent); + c.setTextDatum(top_left); + c.setFont(&fonts::bold); + c.setTextColor(kText); + c.drawString(title.c_str(), box.x + 6, box.y + 4); + + auto lines = wrapText(message, box.w - 12, bodyMeasure(c)); + textLines(c, lines, 0, {box.x + 2, box.y + 20, box.w - 4, 3 * kLineHeight}); + + // Buttons, right-aligned. + c.setFont(&fonts::body); + int x = box.x + box.w - 6; + const auto& buttons = model.buttons(); + for (int i = static_cast(buttons.size()) - 1; i >= 0; i--) { + int w = c.textWidth(buttons[i].c_str()) + 12; + x -= w; + bool selected = i == model.selected(); + if (selected) c.fillRect(x, box.y + box.h - 19, w, 15, kAccent); + else c.drawRect(x, box.y + box.h - 19, w, 15, kMuted); + c.setTextColor(selected ? kBackground : kText); + c.drawString(buttons[i].c_str(), x + 6, box.y + box.h - 17); + x -= 6; + } +} + +void toast(Canvas& c, const Toast& t) { + uint16_t color = t.level == NotificationLevel::Warning ? kWarning + : t.level == NotificationLevel::Message ? kMessage + : kAccent; + c.setFont(&fonts::body); + int w = c.textWidth(t.text.c_str()) + 16; + if (w > kWidth - 8) w = kWidth - 8; + int x = (kWidth - w) / 2, y = kHeight - 22; + c.fillRect(x, y, w, 18, color); + c.setTextColor(kBackground); + c.setTextDatum(top_left); + c.setClipRect(x + 4, y, w - 8, 18); + c.drawString(t.text.c_str(), x + 8, y + 3); + c.clearClipRect(); +} + +} // namespace roro::widgets diff --git a/src/ui/widgets.h b/src/ui/widgets.h new file mode 100644 index 0000000..93894da --- /dev/null +++ b/src/ui/widgets.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include + +#include "canvas.h" +#include "dialog_model.h" +#include "line_editor.h" +#include "list_model.h" +#include "text_wrap.h" +#include "theme.h" +#include "toast_queue.h" + +namespace roro { + +struct StatusInfo { + std::string title; + int batteryPercent = -1; // -1: unknown + std::string clock = "--:--"; + bool sdPresent = false; + int sdLevel = 0; // StorageMonitor level + char compose = 0; // pending Compose Key state + + bool operator==(const StatusInfo& o) const { + return title == o.title && batteryPercent == o.batteryPercent && clock == o.clock && + sdPresent == o.sdPresent && sdLevel == o.sdLevel && compose == o.compose; + } + bool operator!=(const StatusInfo& o) const { return !(*this == o); } +}; + +namespace widgets { + +// Width in pixels of text in the body font. +MeasureText bodyMeasure(Canvas& canvas); + +void statusBar(Canvas& c, const StatusInfo& info); + +// A selectable list; `label(i)` gives row i's text and `value(i)`, if given, a right-aligned value. +void list(Canvas& c, const ListModel& model, theme::Rect area, const std::function& label, + const std::function& value = nullptr); + +// Pre-wrapped lines starting at `firstLine`, with a scroll indicator when they don't all fit. +void textLines(Canvas& c, const std::vector& lines, int firstLine, theme::Rect area); + +// One line of editable text with a cursor; scrolls horizontally to keep the cursor visible. +void lineEditor(Canvas& c, const LineEditor& editor, theme::Rect area); + +void dialog(Canvas& c, const std::string& title, const std::string& message, const DialogModel& model); + +void toast(Canvas& c, const Toast& t); + +} // namespace widgets +} // namespace roro diff --git a/test/memory_store.h b/test/memory_store.h new file mode 100644 index 0000000..3fc2039 --- /dev/null +++ b/test/memory_store.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include + +#include "key_value_store.h" + +// In-memory KeyValueStore for tests. +struct MemoryStore : roro::KeyValueStore { + std::map ints; + std::map strings; + bool getInt(const char* key, int32_t& out) override { + auto it = ints.find(key); + if (it == ints.end()) return false; + out = it->second; + return true; + } + bool getString(const char* key, std::string& out) override { + auto it = strings.find(key); + if (it == strings.end()) return false; + out = it->second; + return true; + } + void putInt(const char* key, int32_t v) override { ints[key] = v; } + void putString(const char* key, const std::string& v) override { strings[key] = v; } +}; diff --git a/test/test_app_manager/test_app_manager.cpp b/test/test_app_manager/test_app_manager.cpp new file mode 100644 index 0000000..f2e869f --- /dev/null +++ b/test/test_app_manager/test_app_manager.cpp @@ -0,0 +1,203 @@ +#include + +#include +#include + +#include "app_manager.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct FakeApp : App { + explicit FakeApp(std::vector* log, const char* tag) : log_(log), tag_(tag) {} + void onEnter() override { log_->push_back(std::string("enter ") + tag_); } + void onExit() override { log_->push_back(std::string("exit ") + tag_); } + bool onKey(const KeyEvent& e) override { + keys.push_back(e); + return handles; + } + void update(uint32_t nowMs) override { lastUpdate = nowMs; } + void draw(Canvas&) override {} + uint32_t lastUpdate = 0; + std::vector keys; + bool handles = false; + + private: + std::vector* log_; + const char* tag_; +}; + +struct Fixture { + std::vector log; + FakeApp launcher{&log, "launcher"}; + FakeApp notes{&log, "notes"}; + FakeApp demo{&log, "demo"}; + AppManager manager{launcher}; + + Fixture() { + manager.registerApp({"notes", "Notes", false, ¬es}); + manager.registerApp({"demo", "Demo", true, &demo}); + manager.begin(); + log.clear(); + } +}; + +void test_launcher_is_in_foreground_after_begin() { + std::vector log; + FakeApp launcher(&log, "launcher"); + AppManager m(launcher); + m.begin(); + TEST_ASSERT_EQUAL_PTR(&launcher, &m.foreground()); + TEST_ASSERT_EQUAL_STRING("enter launcher", log[0].c_str()); +} + +void test_open_switches_foreground_with_exit_then_enter() { + Fixture f; + TEST_ASSERT_TRUE(f.manager.open("notes")); + TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground()); + std::vector expected = {"exit launcher", "enter notes"}; + TEST_ASSERT_TRUE(f.log == expected); +} + +void test_open_unknown_app_keeps_current_foreground() { + Fixture f; + TEST_ASSERT_FALSE(f.manager.open("nope")); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_opening_the_foreground_app_again_does_nothing() { + Fixture f; + f.manager.open("notes"); + f.log.clear(); + TEST_ASSERT_TRUE(f.manager.open("notes")); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_home_key_always_returns_to_launcher_without_reaching_the_app() { + Fixture f; + f.manager.open("notes"); + f.notes.handles = true; + + f.manager.handleKey(KeyEvent::of(Key::Home)); + + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_EQUAL(0, f.notes.keys.size()); +} + +void test_back_goes_to_the_app_first_and_it_can_keep_it() { + Fixture f; + f.manager.open("notes"); + f.notes.handles = true; // e.g. leaving a sub-screen inside the app + + f.manager.handleKey(KeyEvent::of(Key::Back)); + + TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground()); + TEST_ASSERT_EQUAL(1, f.notes.keys.size()); +} + +void test_unhandled_back_returns_to_launcher() { + Fixture f; + f.manager.open("notes"); + + f.manager.handleKey(KeyEvent::of(Key::Back)); + + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); +} + +void test_back_on_launcher_stays_on_launcher() { + Fixture f; + f.manager.handleKey(KeyEvent::of(Key::Back)); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + TEST_ASSERT_TRUE(f.log.empty()); +} + +void test_other_keys_go_to_the_foreground_app() { + Fixture f; + f.manager.open("notes"); + f.manager.handleKey(KeyEvent::character('a')); + TEST_ASSERT_EQUAL(1, f.notes.keys.size()); + TEST_ASSERT_EQUAL('a', f.notes.keys[0].ch); +} + +void test_visible_apps_exclude_hidden_ones_and_keep_registration_order() { + Fixture f; + auto visible = f.manager.visibleApps(); + TEST_ASSERT_EQUAL(1, visible.size()); + TEST_ASSERT_EQUAL_STRING("notes", visible[0]->id); + TEST_ASSERT_TRUE(f.manager.open("demo")); // hidden apps can still be opened +} + +void test_switching_apps_requests_a_redraw() { + Fixture f; + f.manager.takeRedraw(); + f.manager.open("notes"); + TEST_ASSERT_TRUE(f.manager.takeRedraw()); + TEST_ASSERT_FALSE(f.manager.takeRedraw()); +} + +void test_app_can_request_its_own_redraw() { + Fixture f; + f.manager.open("notes"); + f.manager.takeRedraw(); + f.notes.requestRedraw(); + TEST_ASSERT_TRUE(f.manager.takeRedraw()); + TEST_ASSERT_FALSE(f.manager.takeRedraw()); +} + +void test_update_reaches_only_the_foreground_app() { + Fixture f; + f.manager.open("notes"); + f.manager.update(42); + TEST_ASSERT_EQUAL(42, f.notes.lastUpdate); + TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate); +} + +void test_modal_app_keeps_home_and_back() { + Fixture f; + f.manager.openModal("demo"); + f.manager.handleKey(KeyEvent::of(Key::Home)); + f.manager.handleKey(KeyEvent::of(Key::Back)); + TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground()); + TEST_ASSERT_EQUAL(2, f.demo.keys.size()); // both reached the App +} + +void test_ending_the_modal_returns_to_launcher_and_unlocks_home() { + Fixture f; + f.manager.openModal("demo"); + f.manager.endModal(); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); + f.manager.open("notes"); + f.manager.handleKey(KeyEvent::of(Key::Home)); + TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground()); +} + +void test_modal_app_cannot_be_switched_away_by_open() { + Fixture f; + f.manager.openModal("demo"); + TEST_ASSERT_FALSE(f.manager.open("notes")); + TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_launcher_is_in_foreground_after_begin); + RUN_TEST(test_open_switches_foreground_with_exit_then_enter); + RUN_TEST(test_open_unknown_app_keeps_current_foreground); + RUN_TEST(test_opening_the_foreground_app_again_does_nothing); + RUN_TEST(test_home_key_always_returns_to_launcher_without_reaching_the_app); + RUN_TEST(test_back_goes_to_the_app_first_and_it_can_keep_it); + RUN_TEST(test_unhandled_back_returns_to_launcher); + RUN_TEST(test_back_on_launcher_stays_on_launcher); + RUN_TEST(test_other_keys_go_to_the_foreground_app); + RUN_TEST(test_visible_apps_exclude_hidden_ones_and_keep_registration_order); + RUN_TEST(test_switching_apps_requests_a_redraw); + RUN_TEST(test_app_can_request_its_own_redraw); + RUN_TEST(test_update_reaches_only_the_foreground_app); + RUN_TEST(test_modal_app_keeps_home_and_back); + RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home); + RUN_TEST(test_modal_app_cannot_be_switched_away_by_open); + return UNITY_END(); +} diff --git a/test/test_battery/test_battery.cpp b/test/test_battery/test_battery.cpp new file mode 100644 index 0000000..7fe4c01 --- /dev/null +++ b/test/test_battery/test_battery.cpp @@ -0,0 +1,64 @@ +#include + +#include "battery_estimator.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_curve_end_points_and_clamping() { + TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4200)); + TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4350)); + TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(3270)); + TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(2900)); +} + +void test_curve_is_monotonic() { + int previous = 0; + for (int mv = 3200; mv <= 4250; mv += 5) { + int p = BatteryEstimator::percentFromMillivolts(mv); + TEST_ASSERT_TRUE(p >= previous); + previous = p; + } +} + +void test_curve_interpolates_between_points() { + // 3840 mV = 50 %, 3850 mV = 55 % + TEST_ASSERT_EQUAL(50, BatteryEstimator::percentFromMillivolts(3840)); + TEST_ASSERT_INT_WITHIN(1, 52, BatteryEstimator::percentFromMillivolts(3845)); +} + +void test_first_sample_is_reported_immediately() { + BatteryEstimator b; + TEST_ASSERT_FALSE(b.hasReading()); + b.addSample(4000); + TEST_ASSERT_TRUE(b.hasReading()); + TEST_ASSERT_EQUAL(4000, b.millivolts()); +} + +void test_single_outlier_barely_moves_the_estimate() { + BatteryEstimator b; + for (int i = 0; i < 8; i++) b.addSample(3900); + int before = b.percent(); + b.addSample(3500); // e.g. a transmit burst sagging the voltage + TEST_ASSERT_INT_WITHIN(6, before, b.percent()); +} + +void test_estimate_follows_a_sustained_change() { + BatteryEstimator b; + for (int i = 0; i < 8; i++) b.addSample(4100); + for (int i = 0; i < 8; i++) b.addSample(3800); + TEST_ASSERT_EQUAL(3800, b.millivolts()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_curve_end_points_and_clamping); + RUN_TEST(test_curve_is_monotonic); + RUN_TEST(test_curve_interpolates_between_points); + RUN_TEST(test_first_sample_is_reported_immediately); + RUN_TEST(test_single_outlier_barely_moves_the_estimate); + RUN_TEST(test_estimate_follows_a_sustained_change); + return UNITY_END(); +} diff --git a/test/test_clock/test_clock.cpp b/test/test_clock/test_clock.cpp new file mode 100644 index 0000000..197f391 --- /dev/null +++ b/test/test_clock/test_clock.cpp @@ -0,0 +1,74 @@ +#include + +#include "clock_model.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static const char* kBrussels = "CET-1CEST,M3.5.0,M10.5.0/3"; +static const int64_t k2026_07_01_1200Z = 1782907200; +static const int64_t k2026_01_15_1200Z = 1768478400; + +void test_clock_is_unset_until_a_source_sets_it() { + ClockModel c; + TEST_ASSERT_FALSE(c.isSet()); + TEST_ASSERT_EQUAL(static_cast(TimeSource::None), static_cast(c.source())); +} + +void test_time_advances_with_uptime_after_being_set() { + ClockModel c; + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z, TimeSource::Ntp, 5000)); + TEST_ASSERT_TRUE(c.isSet()); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 10, c.utcNow(15000)); +} + +void test_lower_priority_source_does_not_override() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0); + TEST_ASSERT_FALSE(c.set(k2026_07_01_1200Z + 500, TimeSource::Mesh, 0)); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z, c.utcNow(0)); +} + +void test_equal_or_higher_priority_source_overrides() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Mesh, 0); + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 1, TimeSource::Mesh, 0)); + TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 2, TimeSource::Gnss, 0)); + TEST_ASSERT_EQUAL(static_cast(TimeSource::Gnss), static_cast(c.source())); +} + +void test_utc_now_survives_millis_wraparound() { + ClockModel c; + c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0xFFFFFFFFu - 999); + TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 2, c.utcNow(1000)); +} + +void test_relative_age_formatting() { + TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(0).c_str()); + TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(59).c_str()); + TEST_ASSERT_EQUAL_STRING("1 min ago", ClockModel::formatAge(60).c_str()); + TEST_ASSERT_EQUAL_STRING("59 min ago", ClockModel::formatAge(3599).c_str()); + TEST_ASSERT_EQUAL_STRING("1 h ago", ClockModel::formatAge(3600).c_str()); + TEST_ASSERT_EQUAL_STRING("23 h ago", ClockModel::formatAge(86399).c_str()); + TEST_ASSERT_EQUAL_STRING("2 d ago", ClockModel::formatAge(2 * 86400).c_str()); +} + +void test_local_time_in_brussels_summer_and_winter() { + ClockModel::applyTimezone(kBrussels); + TEST_ASSERT_EQUAL_STRING("14:00", ClockModel::formatLocalTime(k2026_07_01_1200Z).c_str()); + TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_clock_is_unset_until_a_source_sets_it); + RUN_TEST(test_time_advances_with_uptime_after_being_set); + RUN_TEST(test_lower_priority_source_does_not_override); + RUN_TEST(test_equal_or_higher_priority_source_overrides); + RUN_TEST(test_utc_now_survives_millis_wraparound); + RUN_TEST(test_relative_age_formatting); + RUN_TEST(test_local_time_in_brussels_summer_and_winter); + return UNITY_END(); +} diff --git a/test/test_dialog_model/test_dialog_model.cpp b/test/test_dialog_model/test_dialog_model.cpp new file mode 100644 index 0000000..97429b5 --- /dev/null +++ b/test/test_dialog_model/test_dialog_model.cpp @@ -0,0 +1,56 @@ +#include + +#include "dialog_model.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_starts_on_the_safe_first_button_and_undecided() { + DialogModel d({"Cancel", "Format"}); + TEST_ASSERT_EQUAL(0, d.selected()); + TEST_ASSERT_EQUAL(DialogModel::kPending, d.result()); +} + +void test_left_right_and_tab_move_between_buttons() { + DialogModel d({"Cancel", "Format"}); + d.onKey(KeyEvent::of(Key::Right)); + TEST_ASSERT_EQUAL(1, d.selected()); + d.onKey(KeyEvent::of(Key::Right)); + TEST_ASSERT_EQUAL(1, d.selected()); + d.onKey(KeyEvent::of(Key::Tab)); + TEST_ASSERT_EQUAL(0, d.selected()); + d.onKey(KeyEvent::of(Key::Left)); + TEST_ASSERT_EQUAL(0, d.selected()); +} + +void test_select_confirms_the_highlighted_button() { + DialogModel d({"Cancel", "Format"}); + d.onKey(KeyEvent::of(Key::Right)); + d.onKey(KeyEvent::of(Key::Select)); + TEST_ASSERT_EQUAL(1, d.result()); +} + +void test_back_cancels() { + DialogModel d({"Cancel", "Format"}); + d.onKey(KeyEvent::of(Key::Right)); + d.onKey(KeyEvent::of(Key::Back)); + TEST_ASSERT_EQUAL(DialogModel::kCancelled, d.result()); +} + +void test_dialog_consumes_every_key_while_open() { + DialogModel d({"OK"}); + TEST_ASSERT_TRUE(d.onKey(KeyEvent::character('x'))); + TEST_ASSERT_TRUE(d.onKey(KeyEvent::of(Key::Back))); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_starts_on_the_safe_first_button_and_undecided); + RUN_TEST(test_left_right_and_tab_move_between_buttons); + RUN_TEST(test_select_confirms_the_highlighted_button); + RUN_TEST(test_back_cancels); + RUN_TEST(test_dialog_consumes_every_key_while_open); + return UNITY_END(); +} diff --git a/test/test_event_bus/test_event_bus.cpp b/test/test_event_bus/test_event_bus.cpp new file mode 100644 index 0000000..588a11f --- /dev/null +++ b/test/test_event_bus/test_event_bus.cpp @@ -0,0 +1,118 @@ +#include + +#include +#include + +#include "event_bus.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_published_events_are_delivered_on_dispatch_not_before() { + EventBus bus(8); + int calls = 0; + bus.subscribe(EventType::BatteryChanged, [&](const Event&) { calls++; }); + + bus.publish(Event{EventType::BatteryChanged}); + TEST_ASSERT_EQUAL(0, calls); + + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(1, calls); +} + +void test_only_subscribers_of_the_event_type_are_called() { + EventBus bus(8); + int battery = 0, notification = 0; + bus.subscribe(EventType::BatteryChanged, [&](const Event&) { battery++; }); + bus.subscribe(EventType::Notification, [&](const Event&) { notification++; }); + + bus.publish(Event{EventType::Notification}); + bus.dispatch(); + + TEST_ASSERT_EQUAL(0, battery); + TEST_ASSERT_EQUAL(1, notification); +} + +void test_every_subscriber_of_a_type_is_called_in_subscription_order() { + EventBus bus(8); + std::vector order; + bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(1); }); + bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(2); }); + + bus.publish(Event{EventType::Notification}); + bus.dispatch(); + + TEST_ASSERT_EQUAL(2, order.size()); + TEST_ASSERT_EQUAL(1, order[0]); + TEST_ASSERT_EQUAL(2, order[1]); +} + +void test_events_keep_publish_order_and_payload() { + EventBus bus(8); + std::vector values; + bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); }); + + bus.publish(Event::withValues(EventType::BatteryChanged, 80)); + bus.publish(Event::withValues(EventType::BatteryChanged, 79)); + bus.dispatch(); + + TEST_ASSERT_EQUAL(2, values.size()); + TEST_ASSERT_EQUAL(80, values[0]); + TEST_ASSERT_EQUAL(79, values[1]); +} + +void test_text_payload_is_copied_and_truncated_safely() { + EventBus bus(8); + std::string received; + bus.subscribe(EventType::Notification, [&](const Event& e) { received = e.text; }); + + std::string longText(200, 'x'); + bus.publish(Event::withText(EventType::Notification, longText.c_str())); + bus.dispatch(); + + TEST_ASSERT_EQUAL(Event::kTextCapacity - 1, received.size()); +} + +void test_full_queue_drops_newest_and_counts_drops() { + EventBus bus(2); + std::vector values; + bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); }); + + TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 1))); + TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 2))); + TEST_ASSERT_FALSE(bus.publish(Event::withValues(EventType::BatteryChanged, 3))); + TEST_ASSERT_EQUAL(1, bus.dropped()); + + bus.dispatch(); + TEST_ASSERT_EQUAL(2, values.size()); + TEST_ASSERT_EQUAL(2, values[1]); +} + +void test_events_published_during_dispatch_wait_for_next_dispatch() { + EventBus bus(8); + int notifications = 0; + bus.subscribe(EventType::BatteryChanged, + [&](const Event&) { bus.publish(Event{EventType::Notification}); }); + bus.subscribe(EventType::Notification, [&](const Event&) { notifications++; }); + + bus.publish(Event{EventType::BatteryChanged}); + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(0, notifications); + + TEST_ASSERT_EQUAL(1, bus.dispatch()); + TEST_ASSERT_EQUAL(1, notifications); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_published_events_are_delivered_on_dispatch_not_before); + RUN_TEST(test_only_subscribers_of_the_event_type_are_called); + RUN_TEST(test_every_subscriber_of_a_type_is_called_in_subscription_order); + RUN_TEST(test_events_keep_publish_order_and_payload); + RUN_TEST(test_text_payload_is_copied_and_truncated_safely); + RUN_TEST(test_full_queue_drops_newest_and_counts_drops); + RUN_TEST(test_events_published_during_dispatch_wait_for_next_dispatch); + return UNITY_END(); +} diff --git a/test/test_key_mapper/test_key_mapper.cpp b/test/test_key_mapper/test_key_mapper.cpp new file mode 100644 index 0000000..99d178f --- /dev/null +++ b/test/test_key_mapper/test_key_mapper.cpp @@ -0,0 +1,245 @@ +#include + +#include + +#include "key_mapper.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static RawKeys chars(std::vector c) { + RawKeys r; + r.chars = c; + return r; +} + +static RawKeys withFn(std::vector c) { + RawKeys r = chars(c); + r.fn = true; + return r; +} + +static RawKeys optOnly() { + RawKeys r; + r.opt = true; + return r; +} + +static std::vector press(KeyMapper& m, const RawKeys& r) { return m.update(r); } + +static void release(KeyMapper& m) { m.update(RawKeys{}); } + +void test_plain_character() { + KeyMapper m; + auto ev = press(m, chars({'a'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(static_cast(Key::Char), static_cast(ev[0].key)); + TEST_ASSERT_EQUAL('a', ev[0].ch); +} + +void test_held_key_is_not_repeated_on_later_changes() { + KeyMapper m; + press(m, chars({'a'})); + auto ev = press(m, chars({'a', 'b'})); // rolling: b pressed while a still held + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL('b', ev[0].ch); +} + +void test_same_key_again_after_release() { + KeyMapper m; + press(m, chars({'a'})); + release(m); + TEST_ASSERT_EQUAL(1, press(m, chars({'a'})).size()); +} + +void test_fn_combos_are_arrows() { + KeyMapper m; + TEST_ASSERT_EQUAL(static_cast(Key::Up), static_cast(press(m, withFn({';'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Down), static_cast(press(m, withFn({'.'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Left), static_cast(press(m, withFn({','}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Right), static_cast(press(m, withFn({'/'}))[0].key)); +} + +void test_backtick_is_back_and_fn_backtick_is_home() { + KeyMapper m; + TEST_ASSERT_EQUAL(static_cast(Key::Back), static_cast(press(m, chars({'`'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Home), static_cast(press(m, withFn({'`'}))[0].key)); +} + +void test_enter_delete_tab() { + KeyMapper m; + RawKeys r; + r.enter = true; + TEST_ASSERT_EQUAL(static_cast(Key::Select), static_cast(press(m, r)[0].key)); + release(m); + r = RawKeys{}; + r.del = true; + TEST_ASSERT_EQUAL(static_cast(Key::Delete), static_cast(press(m, r)[0].key)); + release(m); + r = RawKeys{}; + r.tab = true; + TEST_ASSERT_EQUAL(static_cast(Key::Tab), static_cast(press(m, r)[0].key)); +} + +void test_ctrl_and_alt_flags_are_carried() { + KeyMapper m; + RawKeys r = chars({'c'}); + r.ctrl = true; + auto ev = press(m, r); + TEST_ASSERT_TRUE(ev[0].ctrl); + TEST_ASSERT_EQUAL('c', ev[0].ch); +} + +void test_compose_acute() { + KeyMapper m; + TEST_ASSERT_EQUAL(0, press(m, optOnly()).size()); + release(m); + TEST_ASSERT_EQUAL(0, press(m, chars({'\''})).size()); + release(m); + auto ev = press(m, chars({'e'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL(0xE9, ev[0].ch); // é +} + +void test_compose_while_holding_opt() { + KeyMapper m; + RawKeys r = chars({'`'}); + r.opt = true; + TEST_ASSERT_EQUAL(0, press(m, r).size()); // grave accent, not Back + release(m); + auto ev = press(m, chars({'a'})); + TEST_ASSERT_EQUAL(0xE0, ev[0].ch); // à +} + +void test_compose_table() { + struct Case { + char accent, base; + uint32_t expected; + } cases[] = {{'^', 'e', 0xEA}, {'"', 'e', 0xEB}, {',', 'c', 0xE7}, {',', 'C', 0xC7}, + {'~', 'n', 0xF1}, {'\'', 'E', 0xC9}, {'`', 'u', 0xF9}, {'"', 'i', 0xEF}}; + for (auto& c : cases) { + KeyMapper m; + press(m, optOnly()); + release(m); + press(m, chars({c.accent})); + release(m); + auto ev = press(m, chars({c.base})); + TEST_ASSERT_EQUAL_HEX32(c.expected, ev[0].ch); + } +} + +void test_unknown_combination_types_both_characters() { + KeyMapper m; + press(m, optOnly()); + release(m); + press(m, chars({'\''})); + release(m); + auto ev = press(m, chars({'z'})); + TEST_ASSERT_EQUAL(2, ev.size()); + TEST_ASSERT_EQUAL('\'', ev[0].ch); + TEST_ASSERT_EQUAL('z', ev[1].ch); +} + +void test_opt_followed_by_a_non_accent_types_it_normally() { + KeyMapper m; + press(m, optOnly()); + release(m); + auto ev = press(m, chars({'x'})); + TEST_ASSERT_EQUAL(1, ev.size()); + TEST_ASSERT_EQUAL('x', ev[0].ch); +} + +void test_pressing_opt_twice_cancels() { + KeyMapper m; + press(m, optOnly()); + release(m); + press(m, optOnly()); + release(m); + auto ev = press(m, chars({'`'})); + TEST_ASSERT_EQUAL(static_cast(Key::Back), static_cast(ev[0].key)); +} + +void test_compose_state_is_visible_for_the_ui() { + KeyMapper m; + TEST_ASSERT_EQUAL(0, m.pendingCompose()); + press(m, optOnly()); + TEST_ASSERT_EQUAL('*', m.pendingCompose()); // armed, no accent yet + release(m); + press(m, chars({'^'})); + TEST_ASSERT_EQUAL('^', m.pendingCompose()); +} + +void test_arrow_keys_work_without_fn_when_not_typing() { + KeyMapper m; + m.setTextEntry(false); + TEST_ASSERT_EQUAL(static_cast(Key::Up), static_cast(press(m, chars({';'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Down), static_cast(press(m, chars({'.'}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Left), static_cast(press(m, chars({','}))[0].key)); + release(m); + TEST_ASSERT_EQUAL(static_cast(Key::Right), static_cast(press(m, chars({'/'}))[0].key)); +} + +void test_arrow_keys_type_characters_during_text_entry() { + KeyMapper m; + m.setTextEntry(true); + auto ev = press(m, chars({';'})); + TEST_ASSERT_EQUAL(static_cast(Key::Char), static_cast(ev[0].key)); + TEST_ASSERT_EQUAL(';', ev[0].ch); +} + +void test_fn_arrows_work_in_both_modes() { + KeyMapper m; + m.setTextEntry(false); + TEST_ASSERT_EQUAL(static_cast(Key::Up), static_cast(press(m, withFn({';'}))[0].key)); + release(m); + m.setTextEntry(true); + TEST_ASSERT_EQUAL(static_cast(Key::Up), static_cast(press(m, withFn({';'}))[0].key)); +} + +void test_other_characters_still_type_when_not_typing() { + KeyMapper m; + m.setTextEntry(false); + auto ev = press(m, chars({'w'})); + TEST_ASSERT_EQUAL('w', ev[0].ch); +} + +void test_shifted_arrow_keys_type_their_symbols_when_not_typing() { + // Shift+; is ':' — the driver reports the shifted character, which isn't an arrow. + KeyMapper m; + m.setTextEntry(false); + RawKeys r = chars({':'}); + r.shift = true; + TEST_ASSERT_EQUAL(':', press(m, r)[0].ch); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_plain_character); + RUN_TEST(test_held_key_is_not_repeated_on_later_changes); + RUN_TEST(test_same_key_again_after_release); + RUN_TEST(test_fn_combos_are_arrows); + RUN_TEST(test_backtick_is_back_and_fn_backtick_is_home); + RUN_TEST(test_enter_delete_tab); + RUN_TEST(test_ctrl_and_alt_flags_are_carried); + RUN_TEST(test_compose_acute); + RUN_TEST(test_compose_while_holding_opt); + RUN_TEST(test_compose_table); + RUN_TEST(test_unknown_combination_types_both_characters); + RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally); + RUN_TEST(test_pressing_opt_twice_cancels); + RUN_TEST(test_compose_state_is_visible_for_the_ui); + RUN_TEST(test_arrow_keys_work_without_fn_when_not_typing); + RUN_TEST(test_arrow_keys_type_characters_during_text_entry); + RUN_TEST(test_fn_arrows_work_in_both_modes); + RUN_TEST(test_other_characters_still_type_when_not_typing); + RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing); + return UNITY_END(); +} diff --git a/test/test_line_editor/test_line_editor.cpp b/test/test_line_editor/test_line_editor.cpp new file mode 100644 index 0000000..a1cd1aa --- /dev/null +++ b/test/test_line_editor/test_line_editor.cpp @@ -0,0 +1,104 @@ +#include + +#include "line_editor.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_typing_appends_at_cursor() { + LineEditor e(32); + e.insert('h'); + e.insert('i'); + TEST_ASSERT_EQUAL_STRING("hi", e.text().c_str()); + TEST_ASSERT_EQUAL(2, e.cursor()); +} + +void test_accented_characters_are_stored_as_utf8() { + LineEditor e(32); + e.insert(0xE9); // é + TEST_ASSERT_EQUAL_STRING("\xC3\xA9", e.text().c_str()); + TEST_ASSERT_EQUAL(2, e.cursor()); +} + +void test_cursor_moves_over_whole_characters() { + LineEditor e(32); + e.setText("a\xC3\xA9z"); // aéz + TEST_ASSERT_EQUAL(4, e.cursor()); + e.left(); + TEST_ASSERT_EQUAL(3, e.cursor()); + e.left(); + TEST_ASSERT_EQUAL(1, e.cursor()); // skipped both bytes of é + e.right(); + TEST_ASSERT_EQUAL(3, e.cursor()); +} + +void test_cursor_stops_at_both_ends() { + LineEditor e(32); + e.setText("ab"); + e.right(); + TEST_ASSERT_EQUAL(2, e.cursor()); + e.home(); + e.left(); + TEST_ASSERT_EQUAL(0, e.cursor()); + e.end(); + TEST_ASSERT_EQUAL(2, e.cursor()); +} + +void test_insert_in_the_middle() { + LineEditor e(32); + e.setText("ac"); + e.left(); + e.insert('b'); + TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str()); + TEST_ASSERT_EQUAL(2, e.cursor()); +} + +void test_backspace_removes_the_whole_character_before_the_cursor() { + LineEditor e(32); + e.setText("a\xC3\xA9"); + e.backspace(); + TEST_ASSERT_EQUAL_STRING("a", e.text().c_str()); + e.home(); + e.backspace(); + TEST_ASSERT_EQUAL_STRING("a", e.text().c_str()); +} + +void test_insert_rejected_when_it_would_exceed_max_bytes() { + LineEditor e(3); + e.setText("ab"); + TEST_ASSERT_FALSE(e.insert(0xE9)); // 2 bytes, only 1 left + TEST_ASSERT_TRUE(e.insert('c')); + TEST_ASSERT_FALSE(e.insert('d')); + TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str()); +} + +void test_set_text_truncates_on_a_character_boundary() { + LineEditor e(2); + e.setText("a\xC3\xA9"); + TEST_ASSERT_EQUAL_STRING("a", e.text().c_str()); +} + +void test_three_and_four_byte_characters() { + LineEditor e(32); + e.insert(0x20AC); // € + e.insert(0x1F600); // 😀 + TEST_ASSERT_EQUAL(7, e.text().size()); + e.backspace(); + TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", e.text().c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_typing_appends_at_cursor); + RUN_TEST(test_accented_characters_are_stored_as_utf8); + RUN_TEST(test_cursor_moves_over_whole_characters); + RUN_TEST(test_cursor_stops_at_both_ends); + RUN_TEST(test_insert_in_the_middle); + RUN_TEST(test_backspace_removes_the_whole_character_before_the_cursor); + RUN_TEST(test_insert_rejected_when_it_would_exceed_max_bytes); + RUN_TEST(test_set_text_truncates_on_a_character_boundary); + RUN_TEST(test_three_and_four_byte_characters); + return UNITY_END(); +} diff --git a/test/test_list_model/test_list_model.cpp b/test/test_list_model/test_list_model.cpp new file mode 100644 index 0000000..947cac1 --- /dev/null +++ b/test/test_list_model/test_list_model.cpp @@ -0,0 +1,94 @@ +#include + +#include "list_model.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_empty_list_has_no_selection() { + ListModel l(5); + TEST_ASSERT_EQUAL(-1, l.selected()); + l.down(); + TEST_ASSERT_EQUAL(-1, l.selected()); +} + +void test_selection_starts_at_first_item() { + ListModel l(5); + l.setCount(3); + TEST_ASSERT_EQUAL(0, l.selected()); +} + +void test_down_and_up_move_the_selection() { + ListModel l(5); + l.setCount(3); + l.down(); + TEST_ASSERT_EQUAL(1, l.selected()); + l.up(); + TEST_ASSERT_EQUAL(0, l.selected()); +} + +void test_selection_wraps_around_at_both_ends() { + ListModel l(5); + l.setCount(3); + l.up(); + TEST_ASSERT_EQUAL(2, l.selected()); + l.down(); + TEST_ASSERT_EQUAL(0, l.selected()); +} + +void test_scrolls_to_keep_the_selection_visible() { + ListModel l(3); + l.setCount(10); + for (int i = 0; i < 4; i++) l.down(); // selected 4 + TEST_ASSERT_EQUAL(4, l.selected()); + TEST_ASSERT_EQUAL(2, l.firstVisible()); // rows 2,3,4 + for (int i = 0; i < 3; i++) l.up(); // selected 1 + TEST_ASSERT_EQUAL(1, l.firstVisible()); +} + +void test_wrapping_to_the_end_scrolls_to_the_last_page() { + ListModel l(3); + l.setCount(10); + l.up(); + TEST_ASSERT_EQUAL(9, l.selected()); + TEST_ASSERT_EQUAL(7, l.firstVisible()); +} + +void test_shrinking_the_list_keeps_selection_in_range() { + ListModel l(3); + l.setCount(10); + l.up(); // 9 + l.setCount(4); + TEST_ASSERT_EQUAL(3, l.selected()); + TEST_ASSERT_EQUAL(1, l.firstVisible()); + l.setCount(0); + TEST_ASSERT_EQUAL(-1, l.selected()); +} + +void test_page_moves_by_visible_rows_without_wrapping() { + ListModel l(3); + l.setCount(10); + l.pageDown(); + TEST_ASSERT_EQUAL(3, l.selected()); + l.pageDown(); + l.pageDown(); + l.pageDown(); + TEST_ASSERT_EQUAL(9, l.selected()); + l.pageUp(); + TEST_ASSERT_EQUAL(6, l.selected()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_empty_list_has_no_selection); + RUN_TEST(test_selection_starts_at_first_item); + RUN_TEST(test_down_and_up_move_the_selection); + RUN_TEST(test_selection_wraps_around_at_both_ends); + RUN_TEST(test_scrolls_to_keep_the_selection_visible); + RUN_TEST(test_wrapping_to_the_end_scrolls_to_the_last_page); + RUN_TEST(test_shrinking_the_list_keeps_selection_in_range); + RUN_TEST(test_page_moves_by_visible_rows_without_wrapping); + return UNITY_END(); +} diff --git a/test/test_power/test_power.cpp b/test/test_power/test_power.cpp new file mode 100644 index 0000000..60bd00d --- /dev/null +++ b/test/test_power/test_power.cpp @@ -0,0 +1,102 @@ +#include + +#include "power_policy.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_screen_dims_then_turns_off_after_inactivity() { + PowerPolicy p(30000, 60000); + p.activity(0); + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(29999))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Dimmed), static_cast(p.update(30000))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Off), static_cast(p.update(60000))); +} + +void test_key_on_dimmed_screen_wakes_and_still_acts() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(40000); + TEST_ASSERT_FALSE(p.activity(40000)); // not swallowed + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(40001))); +} + +void test_key_on_off_screen_only_wakes() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(70000); + TEST_ASSERT_TRUE(p.activity(70000)); // swallowed + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(70001))); +} + +void test_new_timeouts_apply_immediately() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.setTimeouts(10000, 20000); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Off), static_cast(p.update(20000))); +} + +void test_notification_lights_an_off_screen_dimmed_until_it_ends() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(70000); // off + p.notify(70000, 73000); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Dimmed), static_cast(p.update(70001))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Dimmed), static_cast(p.update(72999))); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Off), static_cast(p.update(73000))); +} + +void test_notification_does_not_count_as_activity() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.notify(25000, 28000); + TEST_ASSERT_EQUAL(static_cast(ScreenState::Dimmed), static_cast(p.update(30000))); +} + +void test_notification_never_brightens_or_dims_an_on_screen() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.notify(1000, 4000); + TEST_ASSERT_EQUAL(static_cast(ScreenState::On), static_cast(p.update(2000))); +} + +void test_key_on_a_notification_lit_screen_acts() { + PowerPolicy p(30000, 60000); + p.activity(0); + p.update(70000); + p.notify(70000, 73000); + p.update(70001); + TEST_ASSERT_FALSE(p.activity(71000)); // the screen is visible: the key reaches the App +} + +void test_short_press_reported_on_release() { + PowerButton b(2000); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 0))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 500))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::ShortPress), static_cast(b.update(false, 600))); +} + +void test_long_press_fires_once_while_still_held() { + PowerButton b(2000); + b.update(true, 0); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::LongPress), static_cast(b.update(true, 2000))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(true, 3000))); + TEST_ASSERT_EQUAL(static_cast(ButtonAction::None), static_cast(b.update(false, 3100))); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_screen_dims_then_turns_off_after_inactivity); + RUN_TEST(test_key_on_dimmed_screen_wakes_and_still_acts); + RUN_TEST(test_key_on_off_screen_only_wakes); + RUN_TEST(test_new_timeouts_apply_immediately); + RUN_TEST(test_notification_lights_an_off_screen_dimmed_until_it_ends); + RUN_TEST(test_notification_does_not_count_as_activity); + RUN_TEST(test_notification_never_brightens_or_dims_an_on_screen); + RUN_TEST(test_key_on_a_notification_lit_screen_acts); + RUN_TEST(test_short_press_reported_on_release); + RUN_TEST(test_long_press_fires_once_while_still_held); + return UNITY_END(); +} diff --git a/test/test_service_manager/test_service_manager.cpp b/test/test_service_manager/test_service_manager.cpp new file mode 100644 index 0000000..6f31dad --- /dev/null +++ b/test/test_service_manager/test_service_manager.cpp @@ -0,0 +1,127 @@ +#include + +#include +#include + +#include "service_manager.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +struct FakeService : Service { + FakeService(const char* n, uint32_t interval, std::vector* log) + : n_(n), interval_(interval), log_(log) {} + const char* name() const override { return n_; } + uint32_t tickIntervalMs() const override { return interval_; } + void start() override { log_->push_back(std::string("start ") + n_); } + void stop() override { log_->push_back(std::string("stop ") + n_); } + void tick(uint32_t nowMs) override { + ticks.push_back(nowMs); + log_->push_back(std::string("tick ") + n_); + } + std::vector ticks; + + private: + const char* n_; + uint32_t interval_; + std::vector* log_; +}; + +void test_services_start_in_order_and_stop_in_reverse() { + std::vector log; + FakeService a("a", 100, &log), b("b", 100, &log); + ServiceManager m; + m.add(a); + m.add(b); + + m.startAll(0); + m.stopAll(); + + std::vector expected = {"start a", "start b", "stop b", "stop a"}; + TEST_ASSERT_TRUE(log == expected); +} + +void test_service_ticks_immediately_after_start_then_at_its_interval() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + m.startAll(0); + + for (uint32_t t = 0; t <= 250; t += 10) m.tick(t); + + TEST_ASSERT_EQUAL(3, s.ticks.size()); + TEST_ASSERT_EQUAL(0, s.ticks[0]); + TEST_ASSERT_EQUAL(100, s.ticks[1]); + TEST_ASSERT_EQUAL(200, s.ticks[2]); +} + +void test_each_service_keeps_its_own_interval() { + std::vector log; + FakeService fast("fast", 10, &log), slow("slow", 100, &log); + ServiceManager m; + m.add(fast); + m.add(slow); + m.startAll(0); + + for (uint32_t t = 0; t < 100; t += 10) m.tick(t); + + TEST_ASSERT_EQUAL(10, fast.ticks.size()); + TEST_ASSERT_EQUAL(1, slow.ticks.size()); +} + +void test_late_tick_does_not_cause_a_burst_of_catch_up_ticks() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + m.startAll(0); + + m.tick(0); + m.tick(1000); // UI stalled for a second + m.tick(1010); + + TEST_ASSERT_EQUAL(2, s.ticks.size()); +} + +void test_ticking_survives_millis_wraparound() { + std::vector log; + FakeService s("s", 100, &log); + ServiceManager m; + m.add(s); + const uint32_t nearWrap = 0xFFFFFFFFu - 50; + m.startAll(nearWrap); + + m.tick(nearWrap); + m.tick(nearWrap + 60); // wrapped, only 60 ms elapsed + m.tick(nearWrap + 100); // wrapped, 100 ms elapsed + + TEST_ASSERT_EQUAL(2, s.ticks.size()); +} + +void test_services_are_not_ticked_before_start_or_after_stop() { + std::vector log; + FakeService s("s", 10, &log); + ServiceManager m; + m.add(s); + + m.tick(0); + m.startAll(0); + m.stopAll(); + m.tick(100); + + TEST_ASSERT_EQUAL(0, s.ticks.size()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_services_start_in_order_and_stop_in_reverse); + RUN_TEST(test_service_ticks_immediately_after_start_then_at_its_interval); + RUN_TEST(test_each_service_keeps_its_own_interval); + RUN_TEST(test_late_tick_does_not_cause_a_burst_of_catch_up_ticks); + RUN_TEST(test_ticking_survives_millis_wraparound); + RUN_TEST(test_services_are_not_ticked_before_start_or_after_stop); + return UNITY_END(); +} diff --git a/test/test_settings/test_settings.cpp b/test/test_settings/test_settings.cpp new file mode 100644 index 0000000..41b5bca --- /dev/null +++ b/test/test_settings/test_settings.cpp @@ -0,0 +1,167 @@ +#include + +#include +#include +#include + +#include "../memory_store.h" +#include "event_bus.h" +#include "settings.h" + +using namespace roro; + + +void setUp() {} +void tearDown() {} + +void test_defaults_when_store_is_empty() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.getBool(Setting::SetupDone)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL(30, s.getInt(Setting::DimTimeoutS)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::OffTimeoutS)); + TEST_ASSERT_TRUE(s.getBool(Setting::Sound)); + TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw)); + TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str()); + TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str()); +} + +void test_set_persists_and_survives_reload() { + MemoryStore store; + EventBus bus; + { + Settings s(store, bus); + s.load(); + TEST_ASSERT_TRUE(s.setInt(Setting::Brightness, 80)); + TEST_ASSERT_TRUE(s.setString(Setting::LongName, "Clément")); + } + Settings again(store, bus); + again.load(); + TEST_ASSERT_EQUAL(80, again.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL_STRING("Clément", again.getString(Setting::LongName).c_str()); +} + +void test_out_of_range_values_are_rejected_and_not_stored() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 5)); // would make the screen unreadable + TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 101)); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); + TEST_ASSERT_EQUAL(0, store.ints.count(Settings::key(Setting::Brightness))); +} + +void test_corrupt_stored_value_falls_back_to_default() { + MemoryStore store; + EventBus bus; + store.ints[Settings::key(Setting::Brightness)] = 9999; + Settings s(store, bus); + s.load(); + TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness)); +} + +void test_dim_timeout_must_stay_below_off_timeout() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setInt(Setting::DimTimeoutS, 60)); // off is 60 + TEST_ASSERT_FALSE(s.setInt(Setting::OffTimeoutS, 30)); // dim is 30 + TEST_ASSERT_TRUE(s.setInt(Setting::OffTimeoutS, 120)); + TEST_ASSERT_TRUE(s.setInt(Setting::DimTimeoutS, 90)); +} + +void test_short_name_is_one_to_four_bytes() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::ShortName, "")); + TEST_ASSERT_FALSE(s.setString(Setting::ShortName, "ROROX")); + TEST_ASSERT_TRUE(s.setString(Setting::ShortName, "RORO")); +} + +void test_long_name_is_limited_to_39_bytes() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_TRUE(s.setString(Setting::LongName, std::string(39, 'a'))); + TEST_ASSERT_FALSE(s.setString(Setting::LongName, std::string(40, 'a'))); + TEST_ASSERT_FALSE(s.setString(Setting::LongName, "")); +} + +void test_only_known_regions_are_accepted() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::Region, "US915")); + TEST_ASSERT_TRUE(s.setString(Setting::Region, "EU868")); +} + +void test_type_mismatch_is_rejected() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.setString(Setting::Brightness, "80")); + TEST_ASSERT_FALSE(s.setInt(Setting::LongName, 3)); +} + +void test_change_publishes_setting_changed_once() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + int changes = 0; + int32_t which = -1; + bus.subscribe(EventType::SettingChanged, [&](const Event& e) { + changes++; + which = e.a; + }); + + s.setInt(Setting::Brightness, 70); + s.setInt(Setting::Brightness, 70); // same value: no event + s.setInt(Setting::Brightness, 1); // invalid: no event + bus.dispatch(); + + TEST_ASSERT_EQUAL(1, changes); + TEST_ASSERT_EQUAL(static_cast(Setting::Brightness), which); +} + +void test_transmit_requires_confirmed_region() { + MemoryStore store; + EventBus bus; + Settings s(store, bus); + s.load(); + TEST_ASSERT_FALSE(s.canTransmit()); + s.setBool(Setting::RegionConfirmed, true); + TEST_ASSERT_TRUE(s.canTransmit()); +} + +void test_storage_keys_fit_nvs_limit() { + for (int i = 0; i < static_cast(Setting::Count); i++) + TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast(i))) <= 15); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_defaults_when_store_is_empty); + RUN_TEST(test_set_persists_and_survives_reload); + RUN_TEST(test_out_of_range_values_are_rejected_and_not_stored); + RUN_TEST(test_corrupt_stored_value_falls_back_to_default); + RUN_TEST(test_dim_timeout_must_stay_below_off_timeout); + RUN_TEST(test_short_name_is_one_to_four_bytes); + RUN_TEST(test_long_name_is_limited_to_39_bytes); + RUN_TEST(test_only_known_regions_are_accepted); + RUN_TEST(test_type_mismatch_is_rejected); + RUN_TEST(test_change_publishes_setting_changed_once); + RUN_TEST(test_transmit_requires_confirmed_region); + RUN_TEST(test_storage_keys_fit_nvs_limit); + return UNITY_END(); +} diff --git a/test/test_settings_menu/test_settings_menu.cpp b/test/test_settings_menu/test_settings_menu.cpp new file mode 100644 index 0000000..3bc0326 --- /dev/null +++ b/test/test_settings_menu/test_settings_menu.cpp @@ -0,0 +1,113 @@ +#include + +#include "../memory_store.h" +#include "event_bus.h" +#include "settings_menu.h" + +using namespace roro; + +struct Fixture { + MemoryStore store; + EventBus bus; + Settings settings{store, bus}; + SettingsMenu menu{settings}; + Fixture() { settings.load(); } + int row(SettingsMenu::Row r) { + for (int i = 0; i < menu.count(); i++) + if (menu.row(i) == r) return i; + return -1; + } +}; + +void setUp() {} +void tearDown() {} + +void test_every_row_has_a_label() { + Fixture f; + TEST_ASSERT_TRUE(f.menu.count() > 0); + for (int i = 0; i < f.menu.count(); i++) TEST_ASSERT_FALSE(f.menu.label(i).empty()); +} + +void test_values_are_human_readable() { + Fixture f; + TEST_ASSERT_EQUAL_STRING("60%", f.menu.value(f.row(SettingsMenu::Row::Brightness)).c_str()); + TEST_ASSERT_EQUAL_STRING("30 s", f.menu.value(f.row(SettingsMenu::Row::DimTimeout)).c_str()); + TEST_ASSERT_EQUAL_STRING("1 min", f.menu.value(f.row(SettingsMenu::Row::OffTimeout)).c_str()); + TEST_ASSERT_EQUAL_STRING("On", f.menu.value(f.row(SettingsMenu::Row::Sound)).c_str()); + TEST_ASSERT_EQUAL_STRING("Brussels", f.menu.value(f.row(SettingsMenu::Row::Timezone)).c_str()); + TEST_ASSERT_EQUAL_STRING("Raw", f.menu.value(f.row(SettingsMenu::Row::ProbeMacs)).c_str()); +} + +void test_toggle_rows_flip_on_select() { + Fixture f; + int sound = f.row(SettingsMenu::Row::Sound); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Toggle), static_cast(f.menu.kind(sound))); + f.menu.toggle(sound); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::Sound)); +} + +void test_choice_rows_list_options_and_mark_the_current_one() { + Fixture f; + int tz = f.row(SettingsMenu::Row::Timezone); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Choice), static_cast(f.menu.kind(tz))); + auto options = f.menu.choices(tz); + TEST_ASSERT_TRUE(options.size() >= 3); + TEST_ASSERT_EQUAL_STRING("Brussels", options[f.menu.currentChoice(tz)].c_str()); +} + +void test_choosing_applies_the_setting() { + Fixture f; + int tz = f.row(SettingsMenu::Row::Timezone); + auto options = f.menu.choices(tz); + int utc = -1; + for (size_t i = 0; i < options.size(); i++) + if (options[i] == "UTC") utc = static_cast(i); + TEST_ASSERT_TRUE(f.menu.choose(tz, utc).empty()); + TEST_ASSERT_EQUAL_STRING("UTC0", f.settings.getString(Setting::Timezone).c_str()); +} + +void test_choice_that_breaks_a_rule_is_refused_with_a_reason() { + Fixture f; + int dim = f.row(SettingsMenu::Row::DimTimeout); + auto options = f.menu.choices(dim); + int fiveMinutes = -1; + for (size_t i = 0; i < options.size(); i++) + if (options[i] == "5 min") fiveMinutes = static_cast(i); + TEST_ASSERT_TRUE(fiveMinutes >= 0); + std::string error = f.menu.choose(dim, fiveMinutes); // screen off is 1 min + TEST_ASSERT_FALSE(error.empty()); + TEST_ASSERT_EQUAL(30, f.settings.getInt(Setting::DimTimeoutS)); +} + +void test_brightness_steps_with_left_right_within_bounds() { + Fixture f; + int b = f.row(SettingsMenu::Row::Brightness); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Slider), static_cast(f.menu.kind(b))); + f.menu.adjust(b, +1); + TEST_ASSERT_EQUAL(70, f.settings.getInt(Setting::Brightness)); + for (int i = 0; i < 10; i++) f.menu.adjust(b, -1); + TEST_ASSERT_EQUAL(10, f.settings.getInt(Setting::Brightness)); +} + +void test_names_are_text_rows_with_their_byte_limits() { + Fixture f; + int shortName = f.row(SettingsMenu::Row::ShortName); + TEST_ASSERT_EQUAL(static_cast(SettingsMenu::Kind::Text), static_cast(f.menu.kind(shortName))); + TEST_ASSERT_EQUAL(4, f.menu.maxBytes(shortName)); + TEST_ASSERT_FALSE(f.menu.setText(shortName, "TOOLONG").empty()); + TEST_ASSERT_TRUE(f.menu.setText(shortName, "RORO").empty()); + TEST_ASSERT_EQUAL_STRING("RORO", f.menu.value(shortName).c_str()); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_every_row_has_a_label); + RUN_TEST(test_values_are_human_readable); + RUN_TEST(test_toggle_rows_flip_on_select); + RUN_TEST(test_choice_rows_list_options_and_mark_the_current_one); + RUN_TEST(test_choosing_applies_the_setting); + RUN_TEST(test_choice_that_breaks_a_rule_is_refused_with_a_reason); + RUN_TEST(test_brightness_steps_with_left_right_within_bounds); + RUN_TEST(test_names_are_text_rows_with_their_byte_limits); + return UNITY_END(); +} diff --git a/test/test_setup_wizard/test_setup_wizard.cpp b/test/test_setup_wizard/test_setup_wizard.cpp new file mode 100644 index 0000000..cf2116f --- /dev/null +++ b/test/test_setup_wizard/test_setup_wizard.cpp @@ -0,0 +1,121 @@ +#include + +#include "../memory_store.h" +#include "event_bus.h" +#include "setup_wizard.h" + +using namespace roro; + +struct Fixture { + MemoryStore store; + EventBus bus; + Settings settings{store, bus}; + SetupWizard wizard{settings, "roro9stack 2fa4", "2fa4"}; + Fixture() { settings.load(); } +}; + +using Step = SetupWizard::Step; + +void setUp() {} +void tearDown() {} + +static void assertStep(Step expected, const SetupWizard& w) { + TEST_ASSERT_EQUAL(static_cast(expected), static_cast(w.step())); +} + +void test_walks_through_every_step_with_defaults() { + Fixture f; + assertStep(Step::Welcome, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_EQUAL_STRING("roro9stack 2fa4", f.wizard.editor().text().c_str()); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::ShortName, f.wizard); + TEST_ASSERT_EQUAL_STRING("2fa4", f.wizard.editor().text().c_str()); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Region, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Timezone, f.wizard); + TEST_ASSERT_TRUE(f.wizard.next()); + assertStep(Step::Done, f.wizard); + TEST_ASSERT_FALSE(f.wizard.finished()); + TEST_ASSERT_TRUE(f.wizard.next()); + TEST_ASSERT_TRUE(f.wizard.finished()); +} + +void test_nothing_is_saved_until_the_end() { + Fixture f; + for (int i = 0; i < 5; i++) f.wizard.next(); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::SetupDone)); + TEST_ASSERT_FALSE(f.settings.getBool(Setting::RegionConfirmed)); + TEST_ASSERT_TRUE(f.store.ints.empty() && f.store.strings.empty()); +} + +void test_finishing_saves_names_region_timezone_and_marks_setup_done() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText("Cl\xC3\xA9ment"); + f.wizard.next(); + f.wizard.editor().setText("CLEM"); + f.wizard.next(); + f.wizard.next(); // region + f.wizard.selectChoice(1); // second timezone + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL_STRING("Cl\xC3\xA9ment", f.settings.getString(Setting::LongName).c_str()); + TEST_ASSERT_EQUAL_STRING("CLEM", f.settings.getString(Setting::ShortName).c_str()); + TEST_ASSERT_EQUAL_STRING("EU868", f.settings.getString(Setting::Region).c_str()); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::RegionConfirmed)); + TEST_ASSERT_TRUE(f.settings.getBool(Setting::SetupDone)); + TEST_ASSERT_TRUE(f.settings.getString(Setting::Timezone) != "CET-1CEST,M3.5.0,M10.5.0/3"); +} + +void test_empty_name_cannot_advance_and_explains_why() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText(""); + TEST_ASSERT_FALSE(f.wizard.next()); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_FALSE(f.wizard.error().empty()); +} + +void test_short_name_editor_is_limited_to_four_bytes() { + Fixture f; + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL(4, f.wizard.editor().maxBytes()); +} + +void test_back_returns_to_the_previous_step_keeping_input() { + Fixture f; + f.wizard.next(); + f.wizard.editor().setText("Alice"); + f.wizard.next(); + f.wizard.back(); + assertStep(Step::LongName, f.wizard); + TEST_ASSERT_EQUAL_STRING("Alice", f.wizard.editor().text().c_str()); + f.wizard.back(); + f.wizard.back(); + assertStep(Step::Welcome, f.wizard); +} + +void test_region_step_offers_eu868() { + Fixture f; + f.wizard.next(); + f.wizard.next(); + f.wizard.next(); + TEST_ASSERT_EQUAL(1, f.wizard.choiceCount()); + TEST_ASSERT_TRUE(f.wizard.choiceLabel(0).find("EU868") != std::string::npos); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_walks_through_every_step_with_defaults); + RUN_TEST(test_nothing_is_saved_until_the_end); + RUN_TEST(test_finishing_saves_names_region_timezone_and_marks_setup_done); + RUN_TEST(test_empty_name_cannot_advance_and_explains_why); + RUN_TEST(test_short_name_editor_is_limited_to_four_bytes); + RUN_TEST(test_back_returns_to_the_previous_step_keeping_input); + RUN_TEST(test_region_step_offers_eu868); + return UNITY_END(); +} diff --git a/test/test_storage/test_storage.cpp b/test/test_storage/test_storage.cpp new file mode 100644 index 0000000..159ccce --- /dev/null +++ b/test/test_storage/test_storage.cpp @@ -0,0 +1,108 @@ +#include + +#include + +#include "event_bus.h" +#include "storage_monitor.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +static const uint64_t GB = 1024ull * 1024 * 1024; +static const uint64_t MB = 1024ull * 1024; + +struct Fixture { + EventBus bus{16}; + StorageMonitor monitor{bus}; + std::vector notifications; + std::vector thresholds; + Fixture() { + bus.subscribe(EventType::Notification, [&](const Event& e) { notifications.push_back(e); }); + bus.subscribe(EventType::StorageThreshold, [&](const Event& e) { thresholds.push_back(e); }); + } + void update(uint64_t used, uint64_t total = 32 * GB) { + monitor.update(true, total, used); + bus.dispatch(); + } +}; + +void test_no_card_means_nothing_can_be_written() { + Fixture f; + f.monitor.update(false, 0, 0); + TEST_ASSERT_FALSE(f.monitor.state().present); + TEST_ASSERT_FALSE(f.monitor.state().logsAllowed); + TEST_ASSERT_FALSE(f.monitor.state().capturesAllowed); +} + +void test_below_80_percent_everything_is_allowed_quietly() { + Fixture f; + f.update(16 * GB); + TEST_ASSERT_EQUAL(50, f.monitor.state().usedPercent); + TEST_ASSERT_TRUE(f.monitor.state().logsAllowed); + TEST_ASSERT_TRUE(f.monitor.state().capturesAllowed); + TEST_ASSERT_EQUAL(0, f.notifications.size()); +} + +void test_crossing_80_percent_warns_once_per_boot() { + Fixture f; + f.update(26 * GB); // 81 % + TEST_ASSERT_EQUAL(1, f.notifications.size()); + f.update(26 * GB); + f.update(10 * GB); // cleaned up + f.update(27 * GB); // over again, same boot + TEST_ASSERT_EQUAL(1, f.notifications.size()); +} + +void test_warning_is_a_warning_level_notification() { + Fixture f; + f.update(26 * GB); + TEST_ASSERT_EQUAL(static_cast(NotificationLevel::Warning), f.notifications[0].a); +} + +void test_at_90_percent_logs_stop_but_captures_continue() { + Fixture f; + f.update(29 * GB); // 90.6 % + TEST_ASSERT_FALSE(f.monitor.state().logsAllowed); + TEST_ASSERT_TRUE(f.monitor.state().capturesAllowed); +} + +void test_logs_resume_after_clean_up_below_90() { + Fixture f; + f.update(29 * GB); + f.update(20 * GB); + TEST_ASSERT_TRUE(f.monitor.state().logsAllowed); +} + +void test_captures_stop_when_card_is_full() { + Fixture f; + f.update(32 * GB - MB); // less than the 2 MiB reserve left + TEST_ASSERT_FALSE(f.monitor.state().capturesAllowed); +} + +void test_threshold_event_only_when_level_changes() { + Fixture f; + f.update(10 * GB); // level 0, initial + f.update(26 * GB); // 80 + f.update(26 * GB); // still 80 + f.update(29 * GB); // 90 + f.update(32 * GB); // full + TEST_ASSERT_EQUAL(3, f.thresholds.size()); + TEST_ASSERT_EQUAL(80, f.thresholds[0].b); + TEST_ASSERT_EQUAL(90, f.thresholds[1].b); + TEST_ASSERT_EQUAL(100, f.thresholds[2].b); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_no_card_means_nothing_can_be_written); + RUN_TEST(test_below_80_percent_everything_is_allowed_quietly); + RUN_TEST(test_crossing_80_percent_warns_once_per_boot); + RUN_TEST(test_warning_is_a_warning_level_notification); + RUN_TEST(test_at_90_percent_logs_stop_but_captures_continue); + RUN_TEST(test_logs_resume_after_clean_up_below_90); + RUN_TEST(test_captures_stop_when_card_is_full); + RUN_TEST(test_threshold_event_only_when_level_changes); + return UNITY_END(); +} diff --git a/test/test_text_wrap/test_text_wrap.cpp b/test/test_text_wrap/test_text_wrap.cpp new file mode 100644 index 0000000..5197617 --- /dev/null +++ b/test/test_text_wrap/test_text_wrap.cpp @@ -0,0 +1,65 @@ +#include + +#include "text_wrap.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +// Every character (code point) is 6 px wide. +static int measure(const std::string& s) { + int n = 0; + for (unsigned char c : s) + if ((c & 0xC0) != 0x80) n++; + return n * 6; +} + +void test_short_text_is_one_line() { + auto lines = wrapText("hello", 60, measure); + TEST_ASSERT_EQUAL(1, lines.size()); + TEST_ASSERT_EQUAL_STRING("hello", lines[0].c_str()); +} + +void test_wraps_at_word_boundaries() { + auto lines = wrapText("hello big world", 60, measure); // 10 chars per line + TEST_ASSERT_EQUAL(2, lines.size()); + TEST_ASSERT_EQUAL_STRING("hello big", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("world", lines[1].c_str()); +} + +void test_breaks_words_longer_than_a_line() { + auto lines = wrapText("abcdefghijklmnop", 60, measure); + TEST_ASSERT_EQUAL(2, lines.size()); + TEST_ASSERT_EQUAL_STRING("abcdefghij", lines[0].c_str()); + TEST_ASSERT_EQUAL_STRING("klmnop", lines[1].c_str()); +} + +void test_never_splits_a_multibyte_character() { + // 12 x 'é' (2 bytes each) at 10 chars per line + std::string s; + for (int i = 0; i < 12; i++) s += "\xC3\xA9"; + auto lines = wrapText(s, 60, measure); + TEST_ASSERT_EQUAL(2, lines.size()); + TEST_ASSERT_EQUAL(20, lines[0].size()); + TEST_ASSERT_EQUAL(4, lines[1].size()); +} + +void test_explicit_newlines_start_new_lines_and_keep_empty_ones() { + auto lines = wrapText("a\n\nb", 60, measure); + TEST_ASSERT_EQUAL(3, lines.size()); + TEST_ASSERT_EQUAL_STRING("", lines[1].c_str()); +} + +void test_empty_text_is_no_lines() { TEST_ASSERT_EQUAL(0, wrapText("", 60, measure).size()); } + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_short_text_is_one_line); + RUN_TEST(test_wraps_at_word_boundaries); + RUN_TEST(test_breaks_words_longer_than_a_line); + RUN_TEST(test_never_splits_a_multibyte_character); + RUN_TEST(test_explicit_newlines_start_new_lines_and_keep_empty_ones); + RUN_TEST(test_empty_text_is_no_lines); + return UNITY_END(); +} diff --git a/test/test_toast_queue/test_toast_queue.cpp b/test/test_toast_queue/test_toast_queue.cpp new file mode 100644 index 0000000..22e3b17 --- /dev/null +++ b/test/test_toast_queue/test_toast_queue.cpp @@ -0,0 +1,108 @@ +#include + +#include + +#include "toast_queue.h" + +using namespace roro; + +void setUp() {} +void tearDown() {} + +void test_nothing_shown_when_empty() { + ToastQueue q(3000); + TEST_ASSERT_NULL(q.current(0)); +} + +void test_toast_is_shown_for_its_duration() { + ToastQueue q(3000); + q.push("hello", NotificationLevel::Info, 1000); + TEST_ASSERT_NOT_NULL(q.current(1000)); + TEST_ASSERT_EQUAL_STRING("hello", q.current(3999)->text.c_str()); + TEST_ASSERT_NULL(q.current(4000)); +} + +void test_queued_toasts_follow_each_other() { + ToastQueue q(3000); + q.push("one", NotificationLevel::Info, 0); + q.push("two", NotificationLevel::Info, 100); + TEST_ASSERT_EQUAL_STRING("one", q.current(2999)->text.c_str()); + TEST_ASSERT_EQUAL_STRING("two", q.current(3000)->text.c_str()); + TEST_ASSERT_EQUAL_STRING("two", q.current(5999)->text.c_str()); + TEST_ASSERT_NULL(q.current(6000)); +} + +void test_a_toast_queued_later_gets_its_full_duration() { + ToastQueue q(3000); + q.push("one", NotificationLevel::Info, 0); + TEST_ASSERT_NULL(q.current(10000)); + q.push("two", NotificationLevel::Info, 20000); + TEST_ASSERT_NOT_NULL(q.current(22999)); +} + +void test_flood_keeps_the_visible_toast_and_drops_the_oldest_waiting() { + ToastQueue q(3000, 2); + q.push("a", NotificationLevel::Info, 0); + q.push("b", NotificationLevel::Info, 0); + q.push("c", NotificationLevel::Info, 0); + TEST_ASSERT_EQUAL_STRING("a", q.current(0)->text.c_str()); + TEST_ASSERT_EQUAL_STRING("c", q.current(3000)->text.c_str()); +} + +void test_dismiss_shows_the_next_one_immediately() { + ToastQueue q(3000); + q.push("one", NotificationLevel::Info, 0); + q.push("two", NotificationLevel::Info, 0); + q.dismiss(500); + TEST_ASSERT_EQUAL_STRING("two", q.current(500)->text.c_str()); +} + +void test_reports_when_a_new_toast_appears() { + ToastQueue q(3000); + q.push("one", NotificationLevel::Info, 0); + TEST_ASSERT_TRUE(q.takeChanged()); + TEST_ASSERT_FALSE(q.takeChanged()); + q.current(3000); // expires + TEST_ASSERT_TRUE(q.takeChanged()); +} + +void test_asking_with_a_slightly_older_time_does_not_expire_the_toast() { + // The main loop reads the time once, then a Notification pushed later in the same pass is + // stamped a few ms after it. + ToastQueue q(3000); + q.push("late", NotificationLevel::Info, 1005); + TEST_ASSERT_NOT_NULL(q.current(1000)); + TEST_ASSERT_EQUAL_STRING("late", q.current(1000)->text.c_str()); +} + +void test_burst_shows_each_toast_in_turn() { + ToastQueue q(3000); + for (int i = 1; i <= 3; i++) q.push(std::to_string(i), NotificationLevel::Info, 1002); + TEST_ASSERT_EQUAL_STRING("1", q.current(1000)->text.c_str()); + TEST_ASSERT_EQUAL_STRING("2", q.current(4002)->text.c_str()); + TEST_ASSERT_EQUAL_STRING("3", q.current(7002)->text.c_str()); + TEST_ASSERT_NULL(q.current(10002)); +} + +void test_each_toast_has_its_own_id() { + ToastQueue q(3000); + q.push("a", NotificationLevel::Info, 0); + q.push("a", NotificationLevel::Info, 0); // same text, still a different Toast + uint32_t first = q.current(0)->id; + TEST_ASSERT_NOT_EQUAL(first, q.current(3000)->id); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_nothing_shown_when_empty); + RUN_TEST(test_toast_is_shown_for_its_duration); + RUN_TEST(test_queued_toasts_follow_each_other); + RUN_TEST(test_a_toast_queued_later_gets_its_full_duration); + RUN_TEST(test_flood_keeps_the_visible_toast_and_drops_the_oldest_waiting); + RUN_TEST(test_dismiss_shows_the_next_one_immediately); + RUN_TEST(test_reports_when_a_new_toast_appears); + RUN_TEST(test_asking_with_a_slightly_older_time_does_not_expire_the_toast); + RUN_TEST(test_burst_shows_each_toast_in_turn); + RUN_TEST(test_each_toast_has_its_own_id); + return UNITY_END(); +} diff --git a/test/test_version/test_version.cpp b/test/test_version/test_version.cpp new file mode 100644 index 0000000..2dab2a3 --- /dev/null +++ b/test/test_version/test_version.cpp @@ -0,0 +1,22 @@ +#include +#include + +#include "version.h" + +void setUp() {} +void tearDown() {} + +void test_version_is_never_empty() { + TEST_ASSERT_TRUE(std::strlen(roro::versionString()) > 0); +} + +void test_product_name() { + TEST_ASSERT_EQUAL_STRING("roro9stack", roro::kProductName); +} + +int main() { + UNITY_BEGIN(); + RUN_TEST(test_version_is_never_empty); + RUN_TEST(test_product_name); + return UNITY_END(); +}