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