Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8ea020c0e |
@@ -1,3 +0,0 @@
|
||||
.pio/
|
||||
.vscode/
|
||||
*.pyc
|
||||
-105
@@ -1,105 +0,0 @@
|
||||
# roro9stack
|
||||
|
||||
A multi-app handheld operating environment for the M5Stack Cardputer ADV with the Cap LoRa-1262, whose first job is to be a Meshtastic-compatible mesh messenger.
|
||||
|
||||
## Language
|
||||
|
||||
**App**:
|
||||
A foreground, user-facing program chosen from the Launcher. Only one App is on screen at a time; Apps show and act on what Services hold.
|
||||
_Avoid_: program, tool, screen
|
||||
|
||||
**Launcher**:
|
||||
The App that lists every other App and starts them. It's the home screen.
|
||||
_Avoid_: menu, home
|
||||
|
||||
**Service**:
|
||||
A long-lived background capability that keeps running whichever App is in the foreground (e.g. Mesh Service, GNSS Service).
|
||||
_Avoid_: daemon, task, driver
|
||||
|
||||
**Mesh Service**:
|
||||
The Service that keeps the device participating in a mesh network at all times: receiving, relaying, and sending on behalf of Apps.
|
||||
_Avoid_: radio, LoRa app
|
||||
|
||||
**Mesh Protocol**:
|
||||
One on-air language the Mesh Service can speak (Meshtastic first; others may follow). The Mesh Service speaks Mesh Protocols; Apps don't.
|
||||
_Avoid_: stack, mode
|
||||
|
||||
**Node**:
|
||||
Any device participating in the mesh, including this one. It's identified by a node number and has a long and a short name.
|
||||
_Avoid_: peer, device, station
|
||||
|
||||
**Channel**:
|
||||
A named mesh conversation space defined by a name and a shared key. Every Node on the same Channel can read its traffic.
|
||||
_Avoid_: group, room
|
||||
|
||||
**Direct Message**:
|
||||
A text message addressed to a single Node instead of a Channel.
|
||||
_Avoid_: DM (in docs), private message
|
||||
|
||||
**Relaying**:
|
||||
Rebroadcasting another Node's packet so it travels further across the mesh.
|
||||
_Avoid_: forwarding, repeating
|
||||
|
||||
**Wi-Fi Service**:
|
||||
The Service that owns the Wi-Fi radio. It's always in exactly one mode: *Off*, *Connected* (joined to an access point) or *Monitoring* (passively observing). It's Off unless an App asks for another mode.
|
||||
_Avoid_: network manager
|
||||
|
||||
**Status Bar**:
|
||||
The strip shown on every screen with system state at a glance: battery, GNSS fix, mesh activity, Wi-Fi mode and unread count.
|
||||
_Avoid_: header, top bar
|
||||
|
||||
**Notification**:
|
||||
News from a Service that reaches the user while another App is in the foreground. It shows as a brief Toast and may beep or flash.
|
||||
_Avoid_: alert, popup
|
||||
|
||||
**Sniffer**:
|
||||
The LoRa Scanner mode that passively listens with the Mesh Service's own radio settings. It never interrupts mesh participation.
|
||||
_Avoid_: monitor (that word belongs to Wi-Fi)
|
||||
|
||||
**Sweep**:
|
||||
The LoRa Scanner mode that takes over the radio to survey frequencies. It pauses the Mesh Service while active.
|
||||
_Avoid_: scan (ambiguous with Wi-Fi scanning)
|
||||
|
||||
**Region**:
|
||||
The regulatory band plan the device transmits under (here EU868). It sets the allowed frequencies, power and duty cycle. Nothing transmits until it has been confirmed.
|
||||
|
||||
**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
|
||||
|
||||
**Log**:
|
||||
History the device records automatically in the background: mesh message history, IRC logs, probe-request logs.
|
||||
_Avoid_: history file, dump
|
||||
|
||||
**Capture**:
|
||||
Data the user explicitly starts recording, such as Wi-Fi packet captures and LoRa Sniffer captures.
|
||||
_Avoid_: dump, log
|
||||
|
||||
**Storage Warning**:
|
||||
The Notification raised once per boot when the SD card passes 80% full. Selecting it opens Storage Clean-up.
|
||||
|
||||
**Storage Clean-up**:
|
||||
The screen where the user deletes old Logs and Captures by category and age, with a preview of the space freed. Notes are never offered for deletion.
|
||||
|
||||
## Relationships
|
||||
|
||||
- The **Launcher** starts **Apps**. Exactly one **App** is in the foreground.
|
||||
- **Services** keep running underneath, regardless of which **App** is in the foreground.
|
||||
- The **Mesh Service** speaks one or more **Mesh Protocols** and tracks the known **Nodes**.
|
||||
- The **Wi-Fi Service** serves one **App** at a time. Monitoring and Connected are mutually exclusive.
|
||||
- **Services** raise **Notifications**; the **Status Bar** summarises **Service** state.
|
||||
- A **Sweep** pauses the **Mesh Service**; a **Sniffer** does not.
|
||||
- Every transmission is bounded by the **Region** and its **Duty Cycle Budget**.
|
||||
- Past 90% SD usage, **Logs** stop being written; the remaining space is kept for **Captures**. Nothing is deleted without the user's confirmation.
|
||||
- A **Node** may be in several **Channels**. A **Direct Message** targets exactly one **Node**.
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- "LoRa" was used both for the radio and for the mesh. Resolved: say **Mesh Service** for the always-on participation and **Mesh Protocol** for the on-air format. The LoRa Scanner **App** uses the radio directly and doesn't speak a **Mesh Protocol**.
|
||||
@@ -1,674 +0,0 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="117" height="20" role="img" aria-label="lib coverage: 92%">
|
||||
<title>lib coverage: 92% of the lines of lib/ are run by the host tests</title>
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="117" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)"><rect width="84" height="20" fill="#555"/><rect x="84" width="33" height="20" fill="#4c1"/><rect width="117" height="20" fill="url(#s)"/></g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="42.0" y="14">lib coverage</text><text x="100.5" y="14">92%</text></g></svg>
|
||||
|
After Width: | Height: | Size: 768 B |
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,11 +0,0 @@
|
||||
# Own firmware that speaks Meshtastic, not a Meshtastic fork
|
||||
|
||||
We build our own firmware from existing libraries (PlatformIO + Arduino-ESP32, M5Cardputer/M5Unified, RadioLib, TinyGPSPlus, nanopb with Meshtastic's published protobufs). We implement the Meshtastic protocol ourselves as one pluggable Mesh Protocol, rather than forking the Meshtastic firmware, which already supports this exact hardware.
|
||||
|
||||
A fork would give full compatibility on day one, but its architecture is built around being a single-purpose Meshtastic node. That conflicts with our goals: a multi-app OS with a fully custom UX, and room for other mesh protocols (e.g. MeshCore) later.
|
||||
|
||||
## Consequences
|
||||
|
||||
- We accept partial Meshtastic compatibility at first: text on channels, Direct Messages, node list, position and relaying.
|
||||
- The phone-app (BLE) API and PKI-encrypted Direct Messages are deferred, and we must re-implement protocol details ourselves.
|
||||
- Multi-boot with stock Meshtastic via a launcher was rejected: it gives none of our own UX.
|
||||
@@ -1,10 +0,0 @@
|
||||
# Own small widget kit on M5GFX, not LVGL
|
||||
|
||||
The UI is drawn with M5GFX into an off-screen buffer, using a small widget kit we own: list, text view, line editor, dialog, Status Bar and Toast. We chose this over LVGL.
|
||||
|
||||
LVGL would give us ready-made widgets, but it costs roughly 40–60 KB of RAM on a device with no PSRAM. It would also need to coexist with the Mesh Service, the Wi-Fi stack and TLS, and it brings a large learning surface. Most of our Apps are lists and text on a 240×135 screen, and full control of the UX is a primary goal.
|
||||
|
||||
## Consequences
|
||||
|
||||
- We write and maintain our own widgets.
|
||||
- Switching to LVGL later would mean rewriting every App's view layer.
|
||||
@@ -1,89 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,143 +0,0 @@
|
||||
#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 <size_t N>
|
||||
std::vector<std::string> labels(const Choice (&table)[N]) {
|
||||
std::vector<std::string> out;
|
||||
for (auto& c : table) out.push_back(c.label);
|
||||
return out;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
std::vector<std::string> durations(const int (&seconds)[N]) {
|
||||
std::vector<std::string> out;
|
||||
for (int s : seconds) out.push_back(formatSeconds(s));
|
||||
return out;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
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<int>(i);
|
||||
return -1;
|
||||
}
|
||||
|
||||
template <size_t N>
|
||||
int indexOf(const int (&table)[N], int value) {
|
||||
for (size_t i = 0; i < N; i++)
|
||||
if (table[i] == value) return static_cast<int>(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<std::string> 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
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> 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
|
||||
@@ -1,88 +0,0 @@
|
||||
#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
|
||||
@@ -1,46 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#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
|
||||
@@ -1,45 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#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
|
||||
@@ -1,87 +0,0 @@
|
||||
#include "app_manager.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace roro {
|
||||
|
||||
AppManager::AppManager(App& launcher) : launcher_(launcher), foreground_(&launcher) {}
|
||||
|
||||
void AppManager::registerApp(const AppInfo& info) { apps_.push_back(info); }
|
||||
|
||||
std::vector<const AppInfo*> AppManager::visibleApps() const {
|
||||
std::vector<const AppInfo*> 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
|
||||
@@ -1,54 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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<const AppInfo*> 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<AppInfo> apps_;
|
||||
bool redraw_ = true;
|
||||
bool modal_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,43 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
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
|
||||
@@ -1,46 +0,0 @@
|
||||
#include "event_bus.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
EventBus::EventBus(size_t capacity) : queue_(capacity) {}
|
||||
|
||||
bool EventBus::publish(const Event& event) {
|
||||
std::lock_guard<std::mutex> 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<size_t>(type)].push_back(std::move(handler));
|
||||
}
|
||||
|
||||
size_t EventBus::dispatch() {
|
||||
size_t pending;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
pending = count_;
|
||||
}
|
||||
for (size_t i = 0; i < pending; i++) {
|
||||
Event event;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
event = queue_[head_];
|
||||
head_ = (head_ + 1) % queue_.size();
|
||||
count_--;
|
||||
}
|
||||
for (auto& handler : handlers_[static_cast<size_t>(event.type)]) handler(event);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
uint32_t EventBus::dropped() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return dropped_;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,40 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#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<void(const Event&)>;
|
||||
|
||||
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<Event> queue_;
|
||||
size_t head_ = 0;
|
||||
size_t count_ = 0;
|
||||
uint32_t dropped_ = 0;
|
||||
mutable std::mutex mutex_;
|
||||
std::array<std::vector<Handler>, static_cast<size_t>(EventType::Count)> handlers_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -1,33 +0,0 @@
|
||||
#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
|
||||
@@ -1,31 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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<Entry> entries_;
|
||||
bool running_ = false;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,114 +0,0 @@
|
||||
#include "key_mapper.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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<KeyEvent> KeyMapper::update(const RawKeys& keys) {
|
||||
std::vector<KeyEvent> 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<KeyEvent>& 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<unsigned char>(accent), keys));
|
||||
out.push_back(charEvent(static_cast<unsigned char>(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<unsigned char>(c), keys));
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#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<char> 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<KeyEvent> 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<KeyEvent>& out);
|
||||
|
||||
RawKeys previous_;
|
||||
char compose_ = 0;
|
||||
bool textEntry_ = true;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,49 +0,0 @@
|
||||
#include "battery_estimator.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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<int, kWindow> sorted = samples_;
|
||||
auto middle = sorted.begin() + count_ / 2;
|
||||
std::nth_element(sorted.begin(), middle, sorted.begin() + count_);
|
||||
return *middle;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,27 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
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<int, kWindow> samples_{};
|
||||
size_t next_ = 0;
|
||||
size_t count_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,24 +0,0 @@
|
||||
#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
|
||||
@@ -1,49 +0,0 @@
|
||||
#include "clock_model.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
|
||||
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<int64_t>(static_cast<uint32_t>(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<time_t>(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<int>(seconds / 60));
|
||||
else if (seconds < 86400)
|
||||
std::snprintf(buf, sizeof(buf), "%d h ago", static_cast<int>(seconds / 3600));
|
||||
else
|
||||
std::snprintf(buf, sizeof(buf), "%d d ago", static_cast<int>(seconds / 86400));
|
||||
return buf;
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
#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<int32_t>(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
|
||||
@@ -1,57 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -1,118 +0,0 @@
|
||||
#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<size_t>(Setting::Count),
|
||||
"every Setting needs a definition");
|
||||
|
||||
const char* const kKnownRegions[] = {"EU868"};
|
||||
|
||||
const Definition& def(Setting s) { return kDefinitions[static_cast<size_t>(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<Setting>(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<size_t>(s)].i; }
|
||||
bool Settings::getBool(Setting s) const { return getInt(s) != 0; }
|
||||
const std::string& Settings::getString(Setting s) const { return values_[static_cast<size_t>(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<size_t>(d.min) || value.size() > static_cast<size_t>(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<size_t>(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<size_t>(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<size_t>(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<int32_t>(s)));
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,62 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <string>
|
||||
|
||||
#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<Value, static_cast<size_t>(Setting::Count)> values_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,32 +0,0 @@
|
||||
#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<int>(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<int32_t>(NotificationLevel::Warning)));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,36 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#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
|
||||
@@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> buttons) : buttons_(std::move(buttons)) {}
|
||||
|
||||
// Consumes every key while open.
|
||||
bool onKey(const KeyEvent& e) {
|
||||
if (result_ != kPending) return true;
|
||||
int last = static_cast<int>(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<std::string>& buttons() const { return buttons_; }
|
||||
int selected() const { return selected_; }
|
||||
int result() const { return result_; }
|
||||
|
||||
private:
|
||||
std::vector<std::string> buttons_;
|
||||
int selected_ = 0;
|
||||
int result_ = kPending;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,30 +0,0 @@
|
||||
#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
|
||||
@@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
#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
|
||||
@@ -1,32 +0,0 @@
|
||||
#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
|
||||
@@ -1,60 +0,0 @@
|
||||
#include "text_wrap.h"
|
||||
|
||||
#include "utf8.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
namespace {
|
||||
|
||||
void wrapParagraph(const std::string& para, int maxWidth, const MeasureText& measure,
|
||||
std::vector<std::string>& 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<std::string> wrapText(const std::string& text, int maxWidth, const MeasureText& measure) {
|
||||
std::vector<std::string> 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
|
||||
@@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace roro {
|
||||
|
||||
using MeasureText = std::function<int(const std::string&)>;
|
||||
|
||||
// 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<std::string> wrapText(const std::string& text, int maxWidth, const MeasureText& measure);
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,42 +0,0 @@
|
||||
#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<int32_t>(nowMs - shownAtMs_);
|
||||
if (elapsed >= static_cast<int32_t>(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
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
|
||||
#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<Toast> queue_;
|
||||
bool showing_ = false;
|
||||
uint32_t shownAtMs_ = 0;
|
||||
bool changed_ = false;
|
||||
uint32_t nextId_ = 1;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,53 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
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<char>(cp);
|
||||
} else if (cp < 0x800) {
|
||||
out += static_cast<char>(0xC0 | (cp >> 6));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
out += static_cast<char>(0xE0 | (cp >> 12));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
out += static_cast<char>(0xF0 | (cp >> 18));
|
||||
out += static_cast<char>(0x80 | ((cp >> 12) & 0x3F));
|
||||
out += static_cast<char>(0x80 | ((cp >> 6) & 0x3F));
|
||||
out += static_cast<char>(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<unsigned char>(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<unsigned char>(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<unsigned char>(s[pos]))) pos--;
|
||||
return pos;
|
||||
}
|
||||
|
||||
} // namespace roro::utf8
|
||||
@@ -1,14 +0,0 @@
|
||||
#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
|
||||
@@ -1,28 +0,0 @@
|
||||
; 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
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="115" height="20" role="img" aria-label="release: v0.15.0">
|
||||
<title>release: v0.15.0</title>
|
||||
<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
|
||||
<clipPath id="r"><rect width="115" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)"><rect width="54" height="20" fill="#555"/><rect x="54" width="61" height="20" fill="#007ec6"/><rect width="115" height="20" fill="url(#s)"/></g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="27.0" y="14">release</text><text x="84.5" y="14">v0.15.0</text></g></svg>
|
||||
|
After Width: | Height: | Size: 720 B |
@@ -1,14 +0,0 @@
|
||||
# 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" "$@"
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
#!/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'
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,8 +0,0 @@
|
||||
#!/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}"
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
@@ -1,110 +0,0 @@
|
||||
#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<int32_t>(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<int>(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
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<DialogModel> dialog_;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,27 +0,0 @@
|
||||
#include "launcher_app.h"
|
||||
|
||||
#include "ui/widgets.h"
|
||||
|
||||
namespace roro {
|
||||
|
||||
void LauncherApp::onEnter() { list_.setCount(static_cast<int>(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
|
||||
@@ -1,23 +0,0 @@
|
||||
#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
|
||||
@@ -1,210 +0,0 @@
|
||||
#include "settings_app.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#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<int32_t>(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<int>(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<std::string> SettingsApp::storageLines() const {
|
||||
auto s = d_.storage.state();
|
||||
std::vector<std::string> 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<unsigned>(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<std::string> SettingsApp::aboutLines() const {
|
||||
std::vector<std::string> 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
|
||||
@@ -1,63 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string> storageLines() const;
|
||||
std::vector<std::string> 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<DialogModel> eraseDialog_;
|
||||
int editingRow_ = 0;
|
||||
uint32_t lastRefreshMs_ = 0;
|
||||
};
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,99 +0,0 @@
|
||||
#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
|
||||
@@ -1,27 +0,0 @@
|
||||
#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
|
||||
-170
@@ -1,170 +0,0 @@
|
||||
// roro9stack firmware entry point: wires Services, Apps and the UI loop together.
|
||||
#include <M5Cardputer.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
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<uint32_t>((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<unsigned long>(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<unsigned long>(nodeNum() & 0xFFFF));
|
||||
return buf;
|
||||
}
|
||||
inline std::string defaultLongName() { return "roro9stack " + defaultShortName(); }
|
||||
|
||||
} // namespace roro::identity
|
||||
@@ -1,33 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Preferences.h>
|
||||
|
||||
#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
|
||||
@@ -1,17 +0,0 @@
|
||||
#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
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <SPI.h>
|
||||
|
||||
#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
|
||||
@@ -1,19 +0,0 @@
|
||||
#include "battery_service.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#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
|
||||
@@ -1,26 +0,0 @@
|
||||
#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
|
||||
@@ -1,38 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
#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<int32_t>(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
|
||||
@@ -1,60 +0,0 @@
|
||||
#include "power_service.h"
|
||||
|
||||
#include <M5Cardputer.h>
|
||||
#include <esp_sleep.h>
|
||||
|
||||
#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<gpio_num_t>(pins::kButtonG0), 0);
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,38 +0,0 @@
|
||||
#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
|
||||
@@ -1,99 +0,0 @@
|
||||
#include "storage_service.h"
|
||||
|
||||
#include <SD.h>
|
||||
#include <ff.h>
|
||||
#include <sd_diskio.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#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<StorageService*>(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<uint8_t[]> work(new uint8_t[kWorkSize]);
|
||||
LBA_t partitions[] = {100, 0}; // one partition, 100 % of the card
|
||||
char drive[3] = {static_cast<char>('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<int32_t>(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
|
||||
@@ -1,42 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#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
|
||||
@@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <M5GFX.h>
|
||||
|
||||
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
|
||||
@@ -1,224 +0,0 @@
|
||||
// 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<b\27S\263j\246\0'\5\31o\63(\7\262b\247\232\1)"
|
||||
"\10\262b\23S\245\0*\12,b\23\223\32I\305\0+\12-b\233Q\34\62\243\10,\7\233^\247"
|
||||
"J\0-\6\14j\63\2.\7\233^\227V\2/\10\64b_\266\63\0\60\10\263bW\271*\0\61"
|
||||
"\7\263b\227dk\62\12\64b\247bN*\217\0\63\12\64b\63b\324H&\5\64\12\64b\33U"
|
||||
"\65bN\0\65\12\64b\63\364F\62)\0\66\12\64b\247\362\212\62)\0\67\12\64b\63r\314\61"
|
||||
"G\0\70\12\64b\247bRQ&\5\71\12\64b\247\242L;)\0:\7\252b\63\342\10;\10\263"
|
||||
"^g#U\2<\7\263b\233\312\134=\10\34f\63\62\32\1>\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<b\253\62J\32\305\214\4|\5\61cs}\14<b\243Q\314He\224$\0~\7\24"
|
||||
"r\227T\2\240\5\0~\3\241\7\61c\223F\0\242\11\64^\33Gj\316\4\243\12\64b[\215\230"
|
||||
"\223J\0\244\12-b\223\323Lq\345\0\245\13\65b\223S\65d\34\62\2\246\6\71c\263\6\247\12"
|
||||
"<b\67\362\212i\217\4\250\6\213v\223\2\251\12\65b\267\252\71U\265\0\252\7\253j\267\222\36\253"
|
||||
"\10\34f\227TL\1\254\6\233b\63\13\255\5\213j\63\256\11\65b\367\241\226Z\0\257\5\213v\63"
|
||||
"\260\6\233n\327\5\261\10\253b\227VF\3\262\7\253j\327Li\263\7\253j\243/\0\264\6\222r"
|
||||
"\247\0\265\11,^\23\315\221\62\0\266\14\65b\67F\32)\251\230b\12\267\5\11k\23\270\6\222^"
|
||||
"\247\0\271\7\253j\227d\65\272\7\253j\327\215\6\273\10\34f\223bR\11\274\12<b\223[Q\215"
|
||||
"\230\0\275\12<b\223\253\244r\214\3\276\14<b\223Q\314HU#&\0\277\11\263b\227a\212\251"
|
||||
"\2\300\12<b\227QTqL\31\301\11<b[\253\70\246\14\302\12<b\247bRqL\31\303\12"
|
||||
"<b\227TTqL\31\304\12<b\23\63TqL\31\305\12<b\247bRqL\31\306\11\64b"
|
||||
"\67Rk\250J\307\12<^\247\242.\223\214\0\310\12<b\227Q\32z\345\21\311\11<b[\16\275"
|
||||
"\362\10\312\12<b\247\342\330+\217\0\313\12<b\23\63\32z\345\21\314\11\273b\223\323\212\325\0\315"
|
||||
"\11\273b\233\322\212\325\0\316\11\273bW\215\24\253\1\317\11\273b\223\362\212\325\0\320\13\65b\67\343"
|
||||
"He\212i\1\321\12<b\227T\271\324\224\1\322\12<b\227QT\321L\12\323\11<b[\253h"
|
||||
"&\5\324\12<b\247bR\321L\12\325\12<b\227TT\321L\12\326\12<b\23\63T\321L\12"
|
||||
"\327\6\233b\223:\330\11\64b\67\322\221\216\4\331\11<b\227Q\351L\12\332\10<b\333t&\5"
|
||||
"\333\11<b\247\242gR\0\334\12<b\23\63\212\316\244\0\335\13=b_\346Tg\224Q\4\336\12"
|
||||
"\64b\223W\224#e\0\337\11\64b\247\242\352T\11\340\12<b\227Q\306#\312\64\341\11<b["
|
||||
"S#\312\64\342\12<b[e\70\242L\3\343\12<b\227T\306#\312\64\344\11\64bW\303\21e"
|
||||
"\32\345\12<b\247b\222#\312\64\346\11%b\63\242\62G\0\347\10\253^\67\263J\0\350\13<b"
|
||||
"\227Q\306*\215\254\0\351\12<b[S*\215\254\0\352\13<b\247b\206*\215\254\0\353\12\64b"
|
||||
"WC\225FV\0\354\11\273b\223\63\222\325\0\355\11\273b\233\62\222\325\0\356\10\273b\327\226\325\0"
|
||||
"\357\10\263b\223\262\254\6\360\14<b\223b\225Q\32\61)\0\361\12<b\227T\206+\232\1\362\13"
|
||||
"<b\227Q\306*\312\244\0\363\12<b[S*\312\244\0\364\13<b\247b\206*\312\244\0\365\13"
|
||||
"<b\227T\306*\312\244\0\366\12\64b\23\63TQ&\5\367\10\253b\227\321F\11\370\11$b\67"
|
||||
"\322H#\1\371\12<b\227Q\206\321L\3\372\11<b[\343h\246\1\373\12<b\247bF\321L"
|
||||
"\3\374\11\64b\23\63\212f\32\375\13D^[\343(\323\210I\1\376\12<^\223\363\212#\345\14\377"
|
||||
"\14<^\23\63\212\62\215\230\24\0\0\0\0\4\377\377\0";
|
||||
|
||||
} // namespace roro::fontdata
|
||||
@@ -1,20 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <M5GFX.h>
|
||||
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "notifier.h"
|
||||
|
||||
#include <M5Cardputer.h>
|
||||
|
||||
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<NotificationLevel>(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<int32_t>(nowMs - ledOffAtMs_) >= 0) {
|
||||
rgbLedWrite(kLedPin, 0, 0, 0);
|
||||
ledOn_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace roro
|
||||
@@ -1,33 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
|
||||
#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<void(uint32_t nowMs, uint32_t untilMs)> 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
|
||||
@@ -1,24 +0,0 @@
|
||||
#include "screen.h"
|
||||
|
||||
#include <M5Cardputer.h>
|
||||
|
||||
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
|
||||
@@ -1,20 +0,0 @@
|
||||
#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
|
||||
@@ -1,29 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
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
|
||||
@@ -1,143 +0,0 @@
|
||||
#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<int>(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<std::string(int)>& label,
|
||||
const std::function<std::string(int)>& 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<std::string>& 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<int>(lines.size()); row++)
|
||||
c.drawString(lines[firstLine + row].c_str(), area.x + 4, area.y + row * kLineHeight + 1);
|
||||
int total = static_cast<int>(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<int>(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
|
||||
@@ -1,54 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::string(int)>& label,
|
||||
const std::function<std::string(int)>& 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<std::string>& 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
|
||||
File diff suppressed because one or more lines are too long
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "key_value_store.h"
|
||||
|
||||
// In-memory KeyValueStore for tests.
|
||||
struct MemoryStore : roro::KeyValueStore {
|
||||
std::map<std::string, int32_t> ints;
|
||||
std::map<std::string, std::string> strings;
|
||||
bool getInt(const char* key, int32_t& out) override {
|
||||
auto it = ints.find(key);
|
||||
if (it == ints.end()) return false;
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
bool getString(const char* key, std::string& out) override {
|
||||
auto it = strings.find(key);
|
||||
if (it == strings.end()) return false;
|
||||
out = it->second;
|
||||
return true;
|
||||
}
|
||||
void putInt(const char* key, int32_t v) override { ints[key] = v; }
|
||||
void putString(const char* key, const std::string& v) override { strings[key] = v; }
|
||||
};
|
||||
@@ -1,203 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "app_manager.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
struct FakeApp : App {
|
||||
explicit FakeApp(std::vector<std::string>* log, const char* tag) : log_(log), tag_(tag) {}
|
||||
void onEnter() override { log_->push_back(std::string("enter ") + tag_); }
|
||||
void onExit() override { log_->push_back(std::string("exit ") + tag_); }
|
||||
bool onKey(const KeyEvent& e) override {
|
||||
keys.push_back(e);
|
||||
return handles;
|
||||
}
|
||||
void update(uint32_t nowMs) override { lastUpdate = nowMs; }
|
||||
void draw(Canvas&) override {}
|
||||
uint32_t lastUpdate = 0;
|
||||
std::vector<KeyEvent> keys;
|
||||
bool handles = false;
|
||||
|
||||
private:
|
||||
std::vector<std::string>* log_;
|
||||
const char* tag_;
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
std::vector<std::string> log;
|
||||
FakeApp launcher{&log, "launcher"};
|
||||
FakeApp notes{&log, "notes"};
|
||||
FakeApp demo{&log, "demo"};
|
||||
AppManager manager{launcher};
|
||||
|
||||
Fixture() {
|
||||
manager.registerApp({"notes", "Notes", false, ¬es});
|
||||
manager.registerApp({"demo", "Demo", true, &demo});
|
||||
manager.begin();
|
||||
log.clear();
|
||||
}
|
||||
};
|
||||
|
||||
void test_launcher_is_in_foreground_after_begin() {
|
||||
std::vector<std::string> log;
|
||||
FakeApp launcher(&log, "launcher");
|
||||
AppManager m(launcher);
|
||||
m.begin();
|
||||
TEST_ASSERT_EQUAL_PTR(&launcher, &m.foreground());
|
||||
TEST_ASSERT_EQUAL_STRING("enter launcher", log[0].c_str());
|
||||
}
|
||||
|
||||
void test_open_switches_foreground_with_exit_then_enter() {
|
||||
Fixture f;
|
||||
TEST_ASSERT_TRUE(f.manager.open("notes"));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground());
|
||||
std::vector<std::string> expected = {"exit launcher", "enter notes"};
|
||||
TEST_ASSERT_TRUE(f.log == expected);
|
||||
}
|
||||
|
||||
void test_open_unknown_app_keeps_current_foreground() {
|
||||
Fixture f;
|
||||
TEST_ASSERT_FALSE(f.manager.open("nope"));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
TEST_ASSERT_TRUE(f.log.empty());
|
||||
}
|
||||
|
||||
void test_opening_the_foreground_app_again_does_nothing() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.log.clear();
|
||||
TEST_ASSERT_TRUE(f.manager.open("notes"));
|
||||
TEST_ASSERT_TRUE(f.log.empty());
|
||||
}
|
||||
|
||||
void test_home_key_always_returns_to_launcher_without_reaching_the_app() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.notes.handles = true;
|
||||
|
||||
f.manager.handleKey(KeyEvent::of(Key::Home));
|
||||
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
TEST_ASSERT_EQUAL(0, f.notes.keys.size());
|
||||
}
|
||||
|
||||
void test_back_goes_to_the_app_first_and_it_can_keep_it() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.notes.handles = true; // e.g. leaving a sub-screen inside the app
|
||||
|
||||
f.manager.handleKey(KeyEvent::of(Key::Back));
|
||||
|
||||
TEST_ASSERT_EQUAL_PTR(&f.notes, &f.manager.foreground());
|
||||
TEST_ASSERT_EQUAL(1, f.notes.keys.size());
|
||||
}
|
||||
|
||||
void test_unhandled_back_returns_to_launcher() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
|
||||
f.manager.handleKey(KeyEvent::of(Key::Back));
|
||||
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
}
|
||||
|
||||
void test_back_on_launcher_stays_on_launcher() {
|
||||
Fixture f;
|
||||
f.manager.handleKey(KeyEvent::of(Key::Back));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
TEST_ASSERT_TRUE(f.log.empty());
|
||||
}
|
||||
|
||||
void test_other_keys_go_to_the_foreground_app() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.manager.handleKey(KeyEvent::character('a'));
|
||||
TEST_ASSERT_EQUAL(1, f.notes.keys.size());
|
||||
TEST_ASSERT_EQUAL('a', f.notes.keys[0].ch);
|
||||
}
|
||||
|
||||
void test_visible_apps_exclude_hidden_ones_and_keep_registration_order() {
|
||||
Fixture f;
|
||||
auto visible = f.manager.visibleApps();
|
||||
TEST_ASSERT_EQUAL(1, visible.size());
|
||||
TEST_ASSERT_EQUAL_STRING("notes", visible[0]->id);
|
||||
TEST_ASSERT_TRUE(f.manager.open("demo")); // hidden apps can still be opened
|
||||
}
|
||||
|
||||
void test_switching_apps_requests_a_redraw() {
|
||||
Fixture f;
|
||||
f.manager.takeRedraw();
|
||||
f.manager.open("notes");
|
||||
TEST_ASSERT_TRUE(f.manager.takeRedraw());
|
||||
TEST_ASSERT_FALSE(f.manager.takeRedraw());
|
||||
}
|
||||
|
||||
void test_app_can_request_its_own_redraw() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.manager.takeRedraw();
|
||||
f.notes.requestRedraw();
|
||||
TEST_ASSERT_TRUE(f.manager.takeRedraw());
|
||||
TEST_ASSERT_FALSE(f.manager.takeRedraw());
|
||||
}
|
||||
|
||||
void test_update_reaches_only_the_foreground_app() {
|
||||
Fixture f;
|
||||
f.manager.open("notes");
|
||||
f.manager.update(42);
|
||||
TEST_ASSERT_EQUAL(42, f.notes.lastUpdate);
|
||||
TEST_ASSERT_EQUAL(0, f.launcher.lastUpdate);
|
||||
}
|
||||
|
||||
void test_modal_app_keeps_home_and_back() {
|
||||
Fixture f;
|
||||
f.manager.openModal("demo");
|
||||
f.manager.handleKey(KeyEvent::of(Key::Home));
|
||||
f.manager.handleKey(KeyEvent::of(Key::Back));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground());
|
||||
TEST_ASSERT_EQUAL(2, f.demo.keys.size()); // both reached the App
|
||||
}
|
||||
|
||||
void test_ending_the_modal_returns_to_launcher_and_unlocks_home() {
|
||||
Fixture f;
|
||||
f.manager.openModal("demo");
|
||||
f.manager.endModal();
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
f.manager.open("notes");
|
||||
f.manager.handleKey(KeyEvent::of(Key::Home));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.launcher, &f.manager.foreground());
|
||||
}
|
||||
|
||||
void test_modal_app_cannot_be_switched_away_by_open() {
|
||||
Fixture f;
|
||||
f.manager.openModal("demo");
|
||||
TEST_ASSERT_FALSE(f.manager.open("notes"));
|
||||
TEST_ASSERT_EQUAL_PTR(&f.demo, &f.manager.foreground());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_launcher_is_in_foreground_after_begin);
|
||||
RUN_TEST(test_open_switches_foreground_with_exit_then_enter);
|
||||
RUN_TEST(test_open_unknown_app_keeps_current_foreground);
|
||||
RUN_TEST(test_opening_the_foreground_app_again_does_nothing);
|
||||
RUN_TEST(test_home_key_always_returns_to_launcher_without_reaching_the_app);
|
||||
RUN_TEST(test_back_goes_to_the_app_first_and_it_can_keep_it);
|
||||
RUN_TEST(test_unhandled_back_returns_to_launcher);
|
||||
RUN_TEST(test_back_on_launcher_stays_on_launcher);
|
||||
RUN_TEST(test_other_keys_go_to_the_foreground_app);
|
||||
RUN_TEST(test_visible_apps_exclude_hidden_ones_and_keep_registration_order);
|
||||
RUN_TEST(test_switching_apps_requests_a_redraw);
|
||||
RUN_TEST(test_app_can_request_its_own_redraw);
|
||||
RUN_TEST(test_update_reaches_only_the_foreground_app);
|
||||
RUN_TEST(test_modal_app_keeps_home_and_back);
|
||||
RUN_TEST(test_ending_the_modal_returns_to_launcher_and_unlocks_home);
|
||||
RUN_TEST(test_modal_app_cannot_be_switched_away_by_open);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "battery_estimator.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_curve_end_points_and_clamping() {
|
||||
TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4200));
|
||||
TEST_ASSERT_EQUAL(100, BatteryEstimator::percentFromMillivolts(4350));
|
||||
TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(3270));
|
||||
TEST_ASSERT_EQUAL(0, BatteryEstimator::percentFromMillivolts(2900));
|
||||
}
|
||||
|
||||
void test_curve_is_monotonic() {
|
||||
int previous = 0;
|
||||
for (int mv = 3200; mv <= 4250; mv += 5) {
|
||||
int p = BatteryEstimator::percentFromMillivolts(mv);
|
||||
TEST_ASSERT_TRUE(p >= previous);
|
||||
previous = p;
|
||||
}
|
||||
}
|
||||
|
||||
void test_curve_interpolates_between_points() {
|
||||
// 3840 mV = 50 %, 3850 mV = 55 %
|
||||
TEST_ASSERT_EQUAL(50, BatteryEstimator::percentFromMillivolts(3840));
|
||||
TEST_ASSERT_INT_WITHIN(1, 52, BatteryEstimator::percentFromMillivolts(3845));
|
||||
}
|
||||
|
||||
void test_first_sample_is_reported_immediately() {
|
||||
BatteryEstimator b;
|
||||
TEST_ASSERT_FALSE(b.hasReading());
|
||||
b.addSample(4000);
|
||||
TEST_ASSERT_TRUE(b.hasReading());
|
||||
TEST_ASSERT_EQUAL(4000, b.millivolts());
|
||||
}
|
||||
|
||||
void test_single_outlier_barely_moves_the_estimate() {
|
||||
BatteryEstimator b;
|
||||
for (int i = 0; i < 8; i++) b.addSample(3900);
|
||||
int before = b.percent();
|
||||
b.addSample(3500); // e.g. a transmit burst sagging the voltage
|
||||
TEST_ASSERT_INT_WITHIN(6, before, b.percent());
|
||||
}
|
||||
|
||||
void test_estimate_follows_a_sustained_change() {
|
||||
BatteryEstimator b;
|
||||
for (int i = 0; i < 8; i++) b.addSample(4100);
|
||||
for (int i = 0; i < 8; i++) b.addSample(3800);
|
||||
TEST_ASSERT_EQUAL(3800, b.millivolts());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_curve_end_points_and_clamping);
|
||||
RUN_TEST(test_curve_is_monotonic);
|
||||
RUN_TEST(test_curve_interpolates_between_points);
|
||||
RUN_TEST(test_first_sample_is_reported_immediately);
|
||||
RUN_TEST(test_single_outlier_barely_moves_the_estimate);
|
||||
RUN_TEST(test_estimate_follows_a_sustained_change);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "clock_model.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static const char* kBrussels = "CET-1CEST,M3.5.0,M10.5.0/3";
|
||||
static const int64_t k2026_07_01_1200Z = 1782907200;
|
||||
static const int64_t k2026_01_15_1200Z = 1768478400;
|
||||
|
||||
void test_clock_is_unset_until_a_source_sets_it() {
|
||||
ClockModel c;
|
||||
TEST_ASSERT_FALSE(c.isSet());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(TimeSource::None), static_cast<int>(c.source()));
|
||||
}
|
||||
|
||||
void test_time_advances_with_uptime_after_being_set() {
|
||||
ClockModel c;
|
||||
TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z, TimeSource::Ntp, 5000));
|
||||
TEST_ASSERT_TRUE(c.isSet());
|
||||
TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 10, c.utcNow(15000));
|
||||
}
|
||||
|
||||
void test_lower_priority_source_does_not_override() {
|
||||
ClockModel c;
|
||||
c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0);
|
||||
TEST_ASSERT_FALSE(c.set(k2026_07_01_1200Z + 500, TimeSource::Mesh, 0));
|
||||
TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z, c.utcNow(0));
|
||||
}
|
||||
|
||||
void test_equal_or_higher_priority_source_overrides() {
|
||||
ClockModel c;
|
||||
c.set(k2026_07_01_1200Z, TimeSource::Mesh, 0);
|
||||
TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 1, TimeSource::Mesh, 0));
|
||||
TEST_ASSERT_TRUE(c.set(k2026_07_01_1200Z + 2, TimeSource::Gnss, 0));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(TimeSource::Gnss), static_cast<int>(c.source()));
|
||||
}
|
||||
|
||||
void test_utc_now_survives_millis_wraparound() {
|
||||
ClockModel c;
|
||||
c.set(k2026_07_01_1200Z, TimeSource::Ntp, 0xFFFFFFFFu - 999);
|
||||
TEST_ASSERT_EQUAL_INT64(k2026_07_01_1200Z + 2, c.utcNow(1000));
|
||||
}
|
||||
|
||||
void test_relative_age_formatting() {
|
||||
TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(0).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("now", ClockModel::formatAge(59).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("1 min ago", ClockModel::formatAge(60).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("59 min ago", ClockModel::formatAge(3599).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("1 h ago", ClockModel::formatAge(3600).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("23 h ago", ClockModel::formatAge(86399).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("2 d ago", ClockModel::formatAge(2 * 86400).c_str());
|
||||
}
|
||||
|
||||
void test_local_time_in_brussels_summer_and_winter() {
|
||||
ClockModel::applyTimezone(kBrussels);
|
||||
TEST_ASSERT_EQUAL_STRING("14:00", ClockModel::formatLocalTime(k2026_07_01_1200Z).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("13:00", ClockModel::formatLocalTime(k2026_01_15_1200Z).c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_clock_is_unset_until_a_source_sets_it);
|
||||
RUN_TEST(test_time_advances_with_uptime_after_being_set);
|
||||
RUN_TEST(test_lower_priority_source_does_not_override);
|
||||
RUN_TEST(test_equal_or_higher_priority_source_overrides);
|
||||
RUN_TEST(test_utc_now_survives_millis_wraparound);
|
||||
RUN_TEST(test_relative_age_formatting);
|
||||
RUN_TEST(test_local_time_in_brussels_summer_and_winter);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "dialog_model.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_starts_on_the_safe_first_button_and_undecided() {
|
||||
DialogModel d({"Cancel", "Format"});
|
||||
TEST_ASSERT_EQUAL(0, d.selected());
|
||||
TEST_ASSERT_EQUAL(DialogModel::kPending, d.result());
|
||||
}
|
||||
|
||||
void test_left_right_and_tab_move_between_buttons() {
|
||||
DialogModel d({"Cancel", "Format"});
|
||||
d.onKey(KeyEvent::of(Key::Right));
|
||||
TEST_ASSERT_EQUAL(1, d.selected());
|
||||
d.onKey(KeyEvent::of(Key::Right));
|
||||
TEST_ASSERT_EQUAL(1, d.selected());
|
||||
d.onKey(KeyEvent::of(Key::Tab));
|
||||
TEST_ASSERT_EQUAL(0, d.selected());
|
||||
d.onKey(KeyEvent::of(Key::Left));
|
||||
TEST_ASSERT_EQUAL(0, d.selected());
|
||||
}
|
||||
|
||||
void test_select_confirms_the_highlighted_button() {
|
||||
DialogModel d({"Cancel", "Format"});
|
||||
d.onKey(KeyEvent::of(Key::Right));
|
||||
d.onKey(KeyEvent::of(Key::Select));
|
||||
TEST_ASSERT_EQUAL(1, d.result());
|
||||
}
|
||||
|
||||
void test_back_cancels() {
|
||||
DialogModel d({"Cancel", "Format"});
|
||||
d.onKey(KeyEvent::of(Key::Right));
|
||||
d.onKey(KeyEvent::of(Key::Back));
|
||||
TEST_ASSERT_EQUAL(DialogModel::kCancelled, d.result());
|
||||
}
|
||||
|
||||
void test_dialog_consumes_every_key_while_open() {
|
||||
DialogModel d({"OK"});
|
||||
TEST_ASSERT_TRUE(d.onKey(KeyEvent::character('x')));
|
||||
TEST_ASSERT_TRUE(d.onKey(KeyEvent::of(Key::Back)));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_starts_on_the_safe_first_button_and_undecided);
|
||||
RUN_TEST(test_left_right_and_tab_move_between_buttons);
|
||||
RUN_TEST(test_select_confirms_the_highlighted_button);
|
||||
RUN_TEST(test_back_cancels);
|
||||
RUN_TEST(test_dialog_consumes_every_key_while_open);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "event_bus.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_published_events_are_delivered_on_dispatch_not_before() {
|
||||
EventBus bus(8);
|
||||
int calls = 0;
|
||||
bus.subscribe(EventType::BatteryChanged, [&](const Event&) { calls++; });
|
||||
|
||||
bus.publish(Event{EventType::BatteryChanged});
|
||||
TEST_ASSERT_EQUAL(0, calls);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, bus.dispatch());
|
||||
TEST_ASSERT_EQUAL(1, calls);
|
||||
}
|
||||
|
||||
void test_only_subscribers_of_the_event_type_are_called() {
|
||||
EventBus bus(8);
|
||||
int battery = 0, notification = 0;
|
||||
bus.subscribe(EventType::BatteryChanged, [&](const Event&) { battery++; });
|
||||
bus.subscribe(EventType::Notification, [&](const Event&) { notification++; });
|
||||
|
||||
bus.publish(Event{EventType::Notification});
|
||||
bus.dispatch();
|
||||
|
||||
TEST_ASSERT_EQUAL(0, battery);
|
||||
TEST_ASSERT_EQUAL(1, notification);
|
||||
}
|
||||
|
||||
void test_every_subscriber_of_a_type_is_called_in_subscription_order() {
|
||||
EventBus bus(8);
|
||||
std::vector<int> order;
|
||||
bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(1); });
|
||||
bus.subscribe(EventType::Notification, [&](const Event&) { order.push_back(2); });
|
||||
|
||||
bus.publish(Event{EventType::Notification});
|
||||
bus.dispatch();
|
||||
|
||||
TEST_ASSERT_EQUAL(2, order.size());
|
||||
TEST_ASSERT_EQUAL(1, order[0]);
|
||||
TEST_ASSERT_EQUAL(2, order[1]);
|
||||
}
|
||||
|
||||
void test_events_keep_publish_order_and_payload() {
|
||||
EventBus bus(8);
|
||||
std::vector<int32_t> values;
|
||||
bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); });
|
||||
|
||||
bus.publish(Event::withValues(EventType::BatteryChanged, 80));
|
||||
bus.publish(Event::withValues(EventType::BatteryChanged, 79));
|
||||
bus.dispatch();
|
||||
|
||||
TEST_ASSERT_EQUAL(2, values.size());
|
||||
TEST_ASSERT_EQUAL(80, values[0]);
|
||||
TEST_ASSERT_EQUAL(79, values[1]);
|
||||
}
|
||||
|
||||
void test_text_payload_is_copied_and_truncated_safely() {
|
||||
EventBus bus(8);
|
||||
std::string received;
|
||||
bus.subscribe(EventType::Notification, [&](const Event& e) { received = e.text; });
|
||||
|
||||
std::string longText(200, 'x');
|
||||
bus.publish(Event::withText(EventType::Notification, longText.c_str()));
|
||||
bus.dispatch();
|
||||
|
||||
TEST_ASSERT_EQUAL(Event::kTextCapacity - 1, received.size());
|
||||
}
|
||||
|
||||
void test_full_queue_drops_newest_and_counts_drops() {
|
||||
EventBus bus(2);
|
||||
std::vector<int32_t> values;
|
||||
bus.subscribe(EventType::BatteryChanged, [&](const Event& e) { values.push_back(e.a); });
|
||||
|
||||
TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 1)));
|
||||
TEST_ASSERT_TRUE(bus.publish(Event::withValues(EventType::BatteryChanged, 2)));
|
||||
TEST_ASSERT_FALSE(bus.publish(Event::withValues(EventType::BatteryChanged, 3)));
|
||||
TEST_ASSERT_EQUAL(1, bus.dropped());
|
||||
|
||||
bus.dispatch();
|
||||
TEST_ASSERT_EQUAL(2, values.size());
|
||||
TEST_ASSERT_EQUAL(2, values[1]);
|
||||
}
|
||||
|
||||
void test_events_published_during_dispatch_wait_for_next_dispatch() {
|
||||
EventBus bus(8);
|
||||
int notifications = 0;
|
||||
bus.subscribe(EventType::BatteryChanged,
|
||||
[&](const Event&) { bus.publish(Event{EventType::Notification}); });
|
||||
bus.subscribe(EventType::Notification, [&](const Event&) { notifications++; });
|
||||
|
||||
bus.publish(Event{EventType::BatteryChanged});
|
||||
TEST_ASSERT_EQUAL(1, bus.dispatch());
|
||||
TEST_ASSERT_EQUAL(0, notifications);
|
||||
|
||||
TEST_ASSERT_EQUAL(1, bus.dispatch());
|
||||
TEST_ASSERT_EQUAL(1, notifications);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_published_events_are_delivered_on_dispatch_not_before);
|
||||
RUN_TEST(test_only_subscribers_of_the_event_type_are_called);
|
||||
RUN_TEST(test_every_subscriber_of_a_type_is_called_in_subscription_order);
|
||||
RUN_TEST(test_events_keep_publish_order_and_payload);
|
||||
RUN_TEST(test_text_payload_is_copied_and_truncated_safely);
|
||||
RUN_TEST(test_full_queue_drops_newest_and_counts_drops);
|
||||
RUN_TEST(test_events_published_during_dispatch_wait_for_next_dispatch);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "key_mapper.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
static RawKeys chars(std::vector<char> c) {
|
||||
RawKeys r;
|
||||
r.chars = c;
|
||||
return r;
|
||||
}
|
||||
|
||||
static RawKeys withFn(std::vector<char> c) {
|
||||
RawKeys r = chars(c);
|
||||
r.fn = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
static RawKeys optOnly() {
|
||||
RawKeys r;
|
||||
r.opt = true;
|
||||
return r;
|
||||
}
|
||||
|
||||
static std::vector<KeyEvent> press(KeyMapper& m, const RawKeys& r) { return m.update(r); }
|
||||
|
||||
static void release(KeyMapper& m) { m.update(RawKeys{}); }
|
||||
|
||||
void test_plain_character() {
|
||||
KeyMapper m;
|
||||
auto ev = press(m, chars({'a'}));
|
||||
TEST_ASSERT_EQUAL(1, ev.size());
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_EQUAL('a', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_held_key_is_not_repeated_on_later_changes() {
|
||||
KeyMapper m;
|
||||
press(m, chars({'a'}));
|
||||
auto ev = press(m, chars({'a', 'b'})); // rolling: b pressed while a still held
|
||||
TEST_ASSERT_EQUAL(1, ev.size());
|
||||
TEST_ASSERT_EQUAL('b', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_same_key_again_after_release() {
|
||||
KeyMapper m;
|
||||
press(m, chars({'a'}));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(1, press(m, chars({'a'})).size());
|
||||
}
|
||||
|
||||
void test_fn_combos_are_arrows() {
|
||||
KeyMapper m;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, withFn({'.'}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, withFn({','}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, withFn({'/'}))[0].key));
|
||||
}
|
||||
|
||||
void test_backtick_is_back_and_fn_backtick_is_home() {
|
||||
KeyMapper m;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(press(m, chars({'`'}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Home), static_cast<int>(press(m, withFn({'`'}))[0].key));
|
||||
}
|
||||
|
||||
void test_enter_delete_tab() {
|
||||
KeyMapper m;
|
||||
RawKeys r;
|
||||
r.enter = true;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Select), static_cast<int>(press(m, r)[0].key));
|
||||
release(m);
|
||||
r = RawKeys{};
|
||||
r.del = true;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Delete), static_cast<int>(press(m, r)[0].key));
|
||||
release(m);
|
||||
r = RawKeys{};
|
||||
r.tab = true;
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Tab), static_cast<int>(press(m, r)[0].key));
|
||||
}
|
||||
|
||||
void test_ctrl_and_alt_flags_are_carried() {
|
||||
KeyMapper m;
|
||||
RawKeys r = chars({'c'});
|
||||
r.ctrl = true;
|
||||
auto ev = press(m, r);
|
||||
TEST_ASSERT_TRUE(ev[0].ctrl);
|
||||
TEST_ASSERT_EQUAL('c', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_compose_acute() {
|
||||
KeyMapper m;
|
||||
TEST_ASSERT_EQUAL(0, press(m, optOnly()).size());
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(0, press(m, chars({'\''})).size());
|
||||
release(m);
|
||||
auto ev = press(m, chars({'e'}));
|
||||
TEST_ASSERT_EQUAL(1, ev.size());
|
||||
TEST_ASSERT_EQUAL(0xE9, ev[0].ch); // é
|
||||
}
|
||||
|
||||
void test_compose_while_holding_opt() {
|
||||
KeyMapper m;
|
||||
RawKeys r = chars({'`'});
|
||||
r.opt = true;
|
||||
TEST_ASSERT_EQUAL(0, press(m, r).size()); // grave accent, not Back
|
||||
release(m);
|
||||
auto ev = press(m, chars({'a'}));
|
||||
TEST_ASSERT_EQUAL(0xE0, ev[0].ch); // à
|
||||
}
|
||||
|
||||
void test_compose_table() {
|
||||
struct Case {
|
||||
char accent, base;
|
||||
uint32_t expected;
|
||||
} cases[] = {{'^', 'e', 0xEA}, {'"', 'e', 0xEB}, {',', 'c', 0xE7}, {',', 'C', 0xC7},
|
||||
{'~', 'n', 0xF1}, {'\'', 'E', 0xC9}, {'`', 'u', 0xF9}, {'"', 'i', 0xEF}};
|
||||
for (auto& c : cases) {
|
||||
KeyMapper m;
|
||||
press(m, optOnly());
|
||||
release(m);
|
||||
press(m, chars({c.accent}));
|
||||
release(m);
|
||||
auto ev = press(m, chars({c.base}));
|
||||
TEST_ASSERT_EQUAL_HEX32(c.expected, ev[0].ch);
|
||||
}
|
||||
}
|
||||
|
||||
void test_unknown_combination_types_both_characters() {
|
||||
KeyMapper m;
|
||||
press(m, optOnly());
|
||||
release(m);
|
||||
press(m, chars({'\''}));
|
||||
release(m);
|
||||
auto ev = press(m, chars({'z'}));
|
||||
TEST_ASSERT_EQUAL(2, ev.size());
|
||||
TEST_ASSERT_EQUAL('\'', ev[0].ch);
|
||||
TEST_ASSERT_EQUAL('z', ev[1].ch);
|
||||
}
|
||||
|
||||
void test_opt_followed_by_a_non_accent_types_it_normally() {
|
||||
KeyMapper m;
|
||||
press(m, optOnly());
|
||||
release(m);
|
||||
auto ev = press(m, chars({'x'}));
|
||||
TEST_ASSERT_EQUAL(1, ev.size());
|
||||
TEST_ASSERT_EQUAL('x', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_pressing_opt_twice_cancels() {
|
||||
KeyMapper m;
|
||||
press(m, optOnly());
|
||||
release(m);
|
||||
press(m, optOnly());
|
||||
release(m);
|
||||
auto ev = press(m, chars({'`'}));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Back), static_cast<int>(ev[0].key));
|
||||
}
|
||||
|
||||
void test_compose_state_is_visible_for_the_ui() {
|
||||
KeyMapper m;
|
||||
TEST_ASSERT_EQUAL(0, m.pendingCompose());
|
||||
press(m, optOnly());
|
||||
TEST_ASSERT_EQUAL('*', m.pendingCompose()); // armed, no accent yet
|
||||
release(m);
|
||||
press(m, chars({'^'}));
|
||||
TEST_ASSERT_EQUAL('^', m.pendingCompose());
|
||||
}
|
||||
|
||||
void test_arrow_keys_work_without_fn_when_not_typing() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, chars({';'}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Down), static_cast<int>(press(m, chars({'.'}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Left), static_cast<int>(press(m, chars({','}))[0].key));
|
||||
release(m);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Right), static_cast<int>(press(m, chars({'/'}))[0].key));
|
||||
}
|
||||
|
||||
void test_arrow_keys_type_characters_during_text_entry() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(true);
|
||||
auto ev = press(m, chars({';'}));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Char), static_cast<int>(ev[0].key));
|
||||
TEST_ASSERT_EQUAL(';', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_fn_arrows_work_in_both_modes() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
||||
release(m);
|
||||
m.setTextEntry(true);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(Key::Up), static_cast<int>(press(m, withFn({';'}))[0].key));
|
||||
}
|
||||
|
||||
void test_other_characters_still_type_when_not_typing() {
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
auto ev = press(m, chars({'w'}));
|
||||
TEST_ASSERT_EQUAL('w', ev[0].ch);
|
||||
}
|
||||
|
||||
void test_shifted_arrow_keys_type_their_symbols_when_not_typing() {
|
||||
// Shift+; is ':' — the driver reports the shifted character, which isn't an arrow.
|
||||
KeyMapper m;
|
||||
m.setTextEntry(false);
|
||||
RawKeys r = chars({':'});
|
||||
r.shift = true;
|
||||
TEST_ASSERT_EQUAL(':', press(m, r)[0].ch);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_plain_character);
|
||||
RUN_TEST(test_held_key_is_not_repeated_on_later_changes);
|
||||
RUN_TEST(test_same_key_again_after_release);
|
||||
RUN_TEST(test_fn_combos_are_arrows);
|
||||
RUN_TEST(test_backtick_is_back_and_fn_backtick_is_home);
|
||||
RUN_TEST(test_enter_delete_tab);
|
||||
RUN_TEST(test_ctrl_and_alt_flags_are_carried);
|
||||
RUN_TEST(test_compose_acute);
|
||||
RUN_TEST(test_compose_while_holding_opt);
|
||||
RUN_TEST(test_compose_table);
|
||||
RUN_TEST(test_unknown_combination_types_both_characters);
|
||||
RUN_TEST(test_opt_followed_by_a_non_accent_types_it_normally);
|
||||
RUN_TEST(test_pressing_opt_twice_cancels);
|
||||
RUN_TEST(test_compose_state_is_visible_for_the_ui);
|
||||
RUN_TEST(test_arrow_keys_work_without_fn_when_not_typing);
|
||||
RUN_TEST(test_arrow_keys_type_characters_during_text_entry);
|
||||
RUN_TEST(test_fn_arrows_work_in_both_modes);
|
||||
RUN_TEST(test_other_characters_still_type_when_not_typing);
|
||||
RUN_TEST(test_shifted_arrow_keys_type_their_symbols_when_not_typing);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "line_editor.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_typing_appends_at_cursor() {
|
||||
LineEditor e(32);
|
||||
e.insert('h');
|
||||
e.insert('i');
|
||||
TEST_ASSERT_EQUAL_STRING("hi", e.text().c_str());
|
||||
TEST_ASSERT_EQUAL(2, e.cursor());
|
||||
}
|
||||
|
||||
void test_accented_characters_are_stored_as_utf8() {
|
||||
LineEditor e(32);
|
||||
e.insert(0xE9); // é
|
||||
TEST_ASSERT_EQUAL_STRING("\xC3\xA9", e.text().c_str());
|
||||
TEST_ASSERT_EQUAL(2, e.cursor());
|
||||
}
|
||||
|
||||
void test_cursor_moves_over_whole_characters() {
|
||||
LineEditor e(32);
|
||||
e.setText("a\xC3\xA9z"); // aéz
|
||||
TEST_ASSERT_EQUAL(4, e.cursor());
|
||||
e.left();
|
||||
TEST_ASSERT_EQUAL(3, e.cursor());
|
||||
e.left();
|
||||
TEST_ASSERT_EQUAL(1, e.cursor()); // skipped both bytes of é
|
||||
e.right();
|
||||
TEST_ASSERT_EQUAL(3, e.cursor());
|
||||
}
|
||||
|
||||
void test_cursor_stops_at_both_ends() {
|
||||
LineEditor e(32);
|
||||
e.setText("ab");
|
||||
e.right();
|
||||
TEST_ASSERT_EQUAL(2, e.cursor());
|
||||
e.home();
|
||||
e.left();
|
||||
TEST_ASSERT_EQUAL(0, e.cursor());
|
||||
e.end();
|
||||
TEST_ASSERT_EQUAL(2, e.cursor());
|
||||
}
|
||||
|
||||
void test_insert_in_the_middle() {
|
||||
LineEditor e(32);
|
||||
e.setText("ac");
|
||||
e.left();
|
||||
e.insert('b');
|
||||
TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str());
|
||||
TEST_ASSERT_EQUAL(2, e.cursor());
|
||||
}
|
||||
|
||||
void test_backspace_removes_the_whole_character_before_the_cursor() {
|
||||
LineEditor e(32);
|
||||
e.setText("a\xC3\xA9");
|
||||
e.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
|
||||
e.home();
|
||||
e.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
|
||||
}
|
||||
|
||||
void test_insert_rejected_when_it_would_exceed_max_bytes() {
|
||||
LineEditor e(3);
|
||||
e.setText("ab");
|
||||
TEST_ASSERT_FALSE(e.insert(0xE9)); // 2 bytes, only 1 left
|
||||
TEST_ASSERT_TRUE(e.insert('c'));
|
||||
TEST_ASSERT_FALSE(e.insert('d'));
|
||||
TEST_ASSERT_EQUAL_STRING("abc", e.text().c_str());
|
||||
}
|
||||
|
||||
void test_set_text_truncates_on_a_character_boundary() {
|
||||
LineEditor e(2);
|
||||
e.setText("a\xC3\xA9");
|
||||
TEST_ASSERT_EQUAL_STRING("a", e.text().c_str());
|
||||
}
|
||||
|
||||
void test_three_and_four_byte_characters() {
|
||||
LineEditor e(32);
|
||||
e.insert(0x20AC); // €
|
||||
e.insert(0x1F600); // 😀
|
||||
TEST_ASSERT_EQUAL(7, e.text().size());
|
||||
e.backspace();
|
||||
TEST_ASSERT_EQUAL_STRING("\xE2\x82\xAC", e.text().c_str());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_typing_appends_at_cursor);
|
||||
RUN_TEST(test_accented_characters_are_stored_as_utf8);
|
||||
RUN_TEST(test_cursor_moves_over_whole_characters);
|
||||
RUN_TEST(test_cursor_stops_at_both_ends);
|
||||
RUN_TEST(test_insert_in_the_middle);
|
||||
RUN_TEST(test_backspace_removes_the_whole_character_before_the_cursor);
|
||||
RUN_TEST(test_insert_rejected_when_it_would_exceed_max_bytes);
|
||||
RUN_TEST(test_set_text_truncates_on_a_character_boundary);
|
||||
RUN_TEST(test_three_and_four_byte_characters);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "list_model.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_empty_list_has_no_selection() {
|
||||
ListModel l(5);
|
||||
TEST_ASSERT_EQUAL(-1, l.selected());
|
||||
l.down();
|
||||
TEST_ASSERT_EQUAL(-1, l.selected());
|
||||
}
|
||||
|
||||
void test_selection_starts_at_first_item() {
|
||||
ListModel l(5);
|
||||
l.setCount(3);
|
||||
TEST_ASSERT_EQUAL(0, l.selected());
|
||||
}
|
||||
|
||||
void test_down_and_up_move_the_selection() {
|
||||
ListModel l(5);
|
||||
l.setCount(3);
|
||||
l.down();
|
||||
TEST_ASSERT_EQUAL(1, l.selected());
|
||||
l.up();
|
||||
TEST_ASSERT_EQUAL(0, l.selected());
|
||||
}
|
||||
|
||||
void test_selection_wraps_around_at_both_ends() {
|
||||
ListModel l(5);
|
||||
l.setCount(3);
|
||||
l.up();
|
||||
TEST_ASSERT_EQUAL(2, l.selected());
|
||||
l.down();
|
||||
TEST_ASSERT_EQUAL(0, l.selected());
|
||||
}
|
||||
|
||||
void test_scrolls_to_keep_the_selection_visible() {
|
||||
ListModel l(3);
|
||||
l.setCount(10);
|
||||
for (int i = 0; i < 4; i++) l.down(); // selected 4
|
||||
TEST_ASSERT_EQUAL(4, l.selected());
|
||||
TEST_ASSERT_EQUAL(2, l.firstVisible()); // rows 2,3,4
|
||||
for (int i = 0; i < 3; i++) l.up(); // selected 1
|
||||
TEST_ASSERT_EQUAL(1, l.firstVisible());
|
||||
}
|
||||
|
||||
void test_wrapping_to_the_end_scrolls_to_the_last_page() {
|
||||
ListModel l(3);
|
||||
l.setCount(10);
|
||||
l.up();
|
||||
TEST_ASSERT_EQUAL(9, l.selected());
|
||||
TEST_ASSERT_EQUAL(7, l.firstVisible());
|
||||
}
|
||||
|
||||
void test_shrinking_the_list_keeps_selection_in_range() {
|
||||
ListModel l(3);
|
||||
l.setCount(10);
|
||||
l.up(); // 9
|
||||
l.setCount(4);
|
||||
TEST_ASSERT_EQUAL(3, l.selected());
|
||||
TEST_ASSERT_EQUAL(1, l.firstVisible());
|
||||
l.setCount(0);
|
||||
TEST_ASSERT_EQUAL(-1, l.selected());
|
||||
}
|
||||
|
||||
void test_page_moves_by_visible_rows_without_wrapping() {
|
||||
ListModel l(3);
|
||||
l.setCount(10);
|
||||
l.pageDown();
|
||||
TEST_ASSERT_EQUAL(3, l.selected());
|
||||
l.pageDown();
|
||||
l.pageDown();
|
||||
l.pageDown();
|
||||
TEST_ASSERT_EQUAL(9, l.selected());
|
||||
l.pageUp();
|
||||
TEST_ASSERT_EQUAL(6, l.selected());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_empty_list_has_no_selection);
|
||||
RUN_TEST(test_selection_starts_at_first_item);
|
||||
RUN_TEST(test_down_and_up_move_the_selection);
|
||||
RUN_TEST(test_selection_wraps_around_at_both_ends);
|
||||
RUN_TEST(test_scrolls_to_keep_the_selection_visible);
|
||||
RUN_TEST(test_wrapping_to_the_end_scrolls_to_the_last_page);
|
||||
RUN_TEST(test_shrinking_the_list_keeps_selection_in_range);
|
||||
RUN_TEST(test_page_moves_by_visible_rows_without_wrapping);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include "power_policy.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_screen_dims_then_turns_off_after_inactivity() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(29999)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Dimmed), static_cast<int>(p.update(30000)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Off), static_cast<int>(p.update(60000)));
|
||||
}
|
||||
|
||||
void test_key_on_dimmed_screen_wakes_and_still_acts() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.update(40000);
|
||||
TEST_ASSERT_FALSE(p.activity(40000)); // not swallowed
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(40001)));
|
||||
}
|
||||
|
||||
void test_key_on_off_screen_only_wakes() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.update(70000);
|
||||
TEST_ASSERT_TRUE(p.activity(70000)); // swallowed
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(70001)));
|
||||
}
|
||||
|
||||
void test_new_timeouts_apply_immediately() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.setTimeouts(10000, 20000);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Off), static_cast<int>(p.update(20000)));
|
||||
}
|
||||
|
||||
void test_notification_lights_an_off_screen_dimmed_until_it_ends() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.update(70000); // off
|
||||
p.notify(70000, 73000);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Dimmed), static_cast<int>(p.update(70001)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Dimmed), static_cast<int>(p.update(72999)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Off), static_cast<int>(p.update(73000)));
|
||||
}
|
||||
|
||||
void test_notification_does_not_count_as_activity() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.notify(25000, 28000);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::Dimmed), static_cast<int>(p.update(30000)));
|
||||
}
|
||||
|
||||
void test_notification_never_brightens_or_dims_an_on_screen() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.notify(1000, 4000);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ScreenState::On), static_cast<int>(p.update(2000)));
|
||||
}
|
||||
|
||||
void test_key_on_a_notification_lit_screen_acts() {
|
||||
PowerPolicy p(30000, 60000);
|
||||
p.activity(0);
|
||||
p.update(70000);
|
||||
p.notify(70000, 73000);
|
||||
p.update(70001);
|
||||
TEST_ASSERT_FALSE(p.activity(71000)); // the screen is visible: the key reaches the App
|
||||
}
|
||||
|
||||
void test_short_press_reported_on_release() {
|
||||
PowerButton b(2000);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 0)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 500)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::ShortPress), static_cast<int>(b.update(false, 600)));
|
||||
}
|
||||
|
||||
void test_long_press_fires_once_while_still_held() {
|
||||
PowerButton b(2000);
|
||||
b.update(true, 0);
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::LongPress), static_cast<int>(b.update(true, 2000)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(true, 3000)));
|
||||
TEST_ASSERT_EQUAL(static_cast<int>(ButtonAction::None), static_cast<int>(b.update(false, 3100)));
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_screen_dims_then_turns_off_after_inactivity);
|
||||
RUN_TEST(test_key_on_dimmed_screen_wakes_and_still_acts);
|
||||
RUN_TEST(test_key_on_off_screen_only_wakes);
|
||||
RUN_TEST(test_new_timeouts_apply_immediately);
|
||||
RUN_TEST(test_notification_lights_an_off_screen_dimmed_until_it_ends);
|
||||
RUN_TEST(test_notification_does_not_count_as_activity);
|
||||
RUN_TEST(test_notification_never_brightens_or_dims_an_on_screen);
|
||||
RUN_TEST(test_key_on_a_notification_lit_screen_acts);
|
||||
RUN_TEST(test_short_press_reported_on_release);
|
||||
RUN_TEST(test_long_press_fires_once_while_still_held);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "service_manager.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
struct FakeService : Service {
|
||||
FakeService(const char* n, uint32_t interval, std::vector<std::string>* log)
|
||||
: n_(n), interval_(interval), log_(log) {}
|
||||
const char* name() const override { return n_; }
|
||||
uint32_t tickIntervalMs() const override { return interval_; }
|
||||
void start() override { log_->push_back(std::string("start ") + n_); }
|
||||
void stop() override { log_->push_back(std::string("stop ") + n_); }
|
||||
void tick(uint32_t nowMs) override {
|
||||
ticks.push_back(nowMs);
|
||||
log_->push_back(std::string("tick ") + n_);
|
||||
}
|
||||
std::vector<uint32_t> ticks;
|
||||
|
||||
private:
|
||||
const char* n_;
|
||||
uint32_t interval_;
|
||||
std::vector<std::string>* log_;
|
||||
};
|
||||
|
||||
void test_services_start_in_order_and_stop_in_reverse() {
|
||||
std::vector<std::string> log;
|
||||
FakeService a("a", 100, &log), b("b", 100, &log);
|
||||
ServiceManager m;
|
||||
m.add(a);
|
||||
m.add(b);
|
||||
|
||||
m.startAll(0);
|
||||
m.stopAll();
|
||||
|
||||
std::vector<std::string> expected = {"start a", "start b", "stop b", "stop a"};
|
||||
TEST_ASSERT_TRUE(log == expected);
|
||||
}
|
||||
|
||||
void test_service_ticks_immediately_after_start_then_at_its_interval() {
|
||||
std::vector<std::string> log;
|
||||
FakeService s("s", 100, &log);
|
||||
ServiceManager m;
|
||||
m.add(s);
|
||||
m.startAll(0);
|
||||
|
||||
for (uint32_t t = 0; t <= 250; t += 10) m.tick(t);
|
||||
|
||||
TEST_ASSERT_EQUAL(3, s.ticks.size());
|
||||
TEST_ASSERT_EQUAL(0, s.ticks[0]);
|
||||
TEST_ASSERT_EQUAL(100, s.ticks[1]);
|
||||
TEST_ASSERT_EQUAL(200, s.ticks[2]);
|
||||
}
|
||||
|
||||
void test_each_service_keeps_its_own_interval() {
|
||||
std::vector<std::string> log;
|
||||
FakeService fast("fast", 10, &log), slow("slow", 100, &log);
|
||||
ServiceManager m;
|
||||
m.add(fast);
|
||||
m.add(slow);
|
||||
m.startAll(0);
|
||||
|
||||
for (uint32_t t = 0; t < 100; t += 10) m.tick(t);
|
||||
|
||||
TEST_ASSERT_EQUAL(10, fast.ticks.size());
|
||||
TEST_ASSERT_EQUAL(1, slow.ticks.size());
|
||||
}
|
||||
|
||||
void test_late_tick_does_not_cause_a_burst_of_catch_up_ticks() {
|
||||
std::vector<std::string> log;
|
||||
FakeService s("s", 100, &log);
|
||||
ServiceManager m;
|
||||
m.add(s);
|
||||
m.startAll(0);
|
||||
|
||||
m.tick(0);
|
||||
m.tick(1000); // UI stalled for a second
|
||||
m.tick(1010);
|
||||
|
||||
TEST_ASSERT_EQUAL(2, s.ticks.size());
|
||||
}
|
||||
|
||||
void test_ticking_survives_millis_wraparound() {
|
||||
std::vector<std::string> log;
|
||||
FakeService s("s", 100, &log);
|
||||
ServiceManager m;
|
||||
m.add(s);
|
||||
const uint32_t nearWrap = 0xFFFFFFFFu - 50;
|
||||
m.startAll(nearWrap);
|
||||
|
||||
m.tick(nearWrap);
|
||||
m.tick(nearWrap + 60); // wrapped, only 60 ms elapsed
|
||||
m.tick(nearWrap + 100); // wrapped, 100 ms elapsed
|
||||
|
||||
TEST_ASSERT_EQUAL(2, s.ticks.size());
|
||||
}
|
||||
|
||||
void test_services_are_not_ticked_before_start_or_after_stop() {
|
||||
std::vector<std::string> log;
|
||||
FakeService s("s", 10, &log);
|
||||
ServiceManager m;
|
||||
m.add(s);
|
||||
|
||||
m.tick(0);
|
||||
m.startAll(0);
|
||||
m.stopAll();
|
||||
m.tick(100);
|
||||
|
||||
TEST_ASSERT_EQUAL(0, s.ticks.size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_services_start_in_order_and_stop_in_reverse);
|
||||
RUN_TEST(test_service_ticks_immediately_after_start_then_at_its_interval);
|
||||
RUN_TEST(test_each_service_keeps_its_own_interval);
|
||||
RUN_TEST(test_late_tick_does_not_cause_a_burst_of_catch_up_ticks);
|
||||
RUN_TEST(test_ticking_survives_millis_wraparound);
|
||||
RUN_TEST(test_services_are_not_ticked_before_start_or_after_stop);
|
||||
return UNITY_END();
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
#include <unity.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
#include "../memory_store.h"
|
||||
#include "event_bus.h"
|
||||
#include "settings.h"
|
||||
|
||||
using namespace roro;
|
||||
|
||||
|
||||
void setUp() {}
|
||||
void tearDown() {}
|
||||
|
||||
void test_defaults_when_store_is_empty() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.getBool(Setting::SetupDone));
|
||||
TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness));
|
||||
TEST_ASSERT_EQUAL(30, s.getInt(Setting::DimTimeoutS));
|
||||
TEST_ASSERT_EQUAL(60, s.getInt(Setting::OffTimeoutS));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::Sound));
|
||||
TEST_ASSERT_TRUE(s.getBool(Setting::ProbeMacRaw));
|
||||
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
|
||||
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
|
||||
}
|
||||
|
||||
void test_set_persists_and_survives_reload() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
{
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_TRUE(s.setInt(Setting::Brightness, 80));
|
||||
TEST_ASSERT_TRUE(s.setString(Setting::LongName, "Clément"));
|
||||
}
|
||||
Settings again(store, bus);
|
||||
again.load();
|
||||
TEST_ASSERT_EQUAL(80, again.getInt(Setting::Brightness));
|
||||
TEST_ASSERT_EQUAL_STRING("Clément", again.getString(Setting::LongName).c_str());
|
||||
}
|
||||
|
||||
void test_out_of_range_values_are_rejected_and_not_stored() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 5)); // would make the screen unreadable
|
||||
TEST_ASSERT_FALSE(s.setInt(Setting::Brightness, 101));
|
||||
TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness));
|
||||
TEST_ASSERT_EQUAL(0, store.ints.count(Settings::key(Setting::Brightness)));
|
||||
}
|
||||
|
||||
void test_corrupt_stored_value_falls_back_to_default() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
store.ints[Settings::key(Setting::Brightness)] = 9999;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_EQUAL(60, s.getInt(Setting::Brightness));
|
||||
}
|
||||
|
||||
void test_dim_timeout_must_stay_below_off_timeout() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.setInt(Setting::DimTimeoutS, 60)); // off is 60
|
||||
TEST_ASSERT_FALSE(s.setInt(Setting::OffTimeoutS, 30)); // dim is 30
|
||||
TEST_ASSERT_TRUE(s.setInt(Setting::OffTimeoutS, 120));
|
||||
TEST_ASSERT_TRUE(s.setInt(Setting::DimTimeoutS, 90));
|
||||
}
|
||||
|
||||
void test_short_name_is_one_to_four_bytes() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::ShortName, ""));
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::ShortName, "ROROX"));
|
||||
TEST_ASSERT_TRUE(s.setString(Setting::ShortName, "RORO"));
|
||||
}
|
||||
|
||||
void test_long_name_is_limited_to_39_bytes() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_TRUE(s.setString(Setting::LongName, std::string(39, 'a')));
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::LongName, std::string(40, 'a')));
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::LongName, ""));
|
||||
}
|
||||
|
||||
void test_only_known_regions_are_accepted() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::Region, "US915"));
|
||||
TEST_ASSERT_TRUE(s.setString(Setting::Region, "EU868"));
|
||||
}
|
||||
|
||||
void test_type_mismatch_is_rejected() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.setString(Setting::Brightness, "80"));
|
||||
TEST_ASSERT_FALSE(s.setInt(Setting::LongName, 3));
|
||||
}
|
||||
|
||||
void test_change_publishes_setting_changed_once() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
int changes = 0;
|
||||
int32_t which = -1;
|
||||
bus.subscribe(EventType::SettingChanged, [&](const Event& e) {
|
||||
changes++;
|
||||
which = e.a;
|
||||
});
|
||||
|
||||
s.setInt(Setting::Brightness, 70);
|
||||
s.setInt(Setting::Brightness, 70); // same value: no event
|
||||
s.setInt(Setting::Brightness, 1); // invalid: no event
|
||||
bus.dispatch();
|
||||
|
||||
TEST_ASSERT_EQUAL(1, changes);
|
||||
TEST_ASSERT_EQUAL(static_cast<int32_t>(Setting::Brightness), which);
|
||||
}
|
||||
|
||||
void test_transmit_requires_confirmed_region() {
|
||||
MemoryStore store;
|
||||
EventBus bus;
|
||||
Settings s(store, bus);
|
||||
s.load();
|
||||
TEST_ASSERT_FALSE(s.canTransmit());
|
||||
s.setBool(Setting::RegionConfirmed, true);
|
||||
TEST_ASSERT_TRUE(s.canTransmit());
|
||||
}
|
||||
|
||||
void test_storage_keys_fit_nvs_limit() {
|
||||
for (int i = 0; i < static_cast<int>(Setting::Count); i++)
|
||||
TEST_ASSERT_TRUE(std::strlen(Settings::key(static_cast<Setting>(i))) <= 15);
|
||||
}
|
||||
|
||||
int main() {
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_defaults_when_store_is_empty);
|
||||
RUN_TEST(test_set_persists_and_survives_reload);
|
||||
RUN_TEST(test_out_of_range_values_are_rejected_and_not_stored);
|
||||
RUN_TEST(test_corrupt_stored_value_falls_back_to_default);
|
||||
RUN_TEST(test_dim_timeout_must_stay_below_off_timeout);
|
||||
RUN_TEST(test_short_name_is_one_to_four_bytes);
|
||||
RUN_TEST(test_long_name_is_limited_to_39_bytes);
|
||||
RUN_TEST(test_only_known_regions_are_accepted);
|
||||
RUN_TEST(test_type_mismatch_is_rejected);
|
||||
RUN_TEST(test_change_publishes_setting_changed_once);
|
||||
RUN_TEST(test_transmit_requires_confirmed_region);
|
||||
RUN_TEST(test_storage_keys_fit_nvs_limit);
|
||||
return UNITY_END();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user