diff --git a/Apps/Maps/CHANGELOG.md b/Apps/Maps/CHANGELOG.md
new file mode 100644
index 0000000..53d5968
--- /dev/null
+++ b/Apps/Maps/CHANGELOG.md
@@ -0,0 +1,158 @@
+# Changelog
+
+## 0.2.0
+
+Labels, map rotation and heading-up, plus menu, list, trackball and download fixes.
+
+**Labels**
+- Place, street, river, water and peak names, placed for each view so they never overlap; street names
+ follow the street and stay upright; a light halo.
+- Settings → Labels (Off / Few / Normal), Settings → Latin names (the Latin-script name where the map data
+ has one). Names in scripts the label font has no letters for (Japanese, Chinese, Arabic and others) always
+ show their Latin-script name.
+- Label fonts: the app ships a subset of Adwaita Sans with Latin (Extended-A and -B), Greek, Cyrillic and
+ Vietnamese letters, so names like Štefánikova, Łódź, İstanbul, Москва or Hà Nội show fully; the device
+ makes the fonts from it once (about 7 s in the background on first start) and keeps them on the card.
+- Map data downloaded with 0.1.0 is upgraded in place in idle time, the tiles on screen first; no download
+ is needed.
+
+**Rotation**
+- Q / E turn the map by 15°, N or the compass (top right, only shown on a turned map) turns it back to
+ north; two fingers turn it once twisted past 45° (15° when already turned), so pinching does not turn it
+ by accident.
+- ⌖ / G now cycles: follow north up → follow with the direction of travel up (from the GPS course while
+ moving faster than about 1 m/s, smoothed, held when slower) → not following.
+- Keys and dragging move a turned map in screen directions; labels stay upright; the download frame always
+ works north up.
+- On monochrome and e-paper displays turning is not animated.
+
+**Menu and lists**
+- Menu → Location switches Tactility's location service on or off (with a message).
+- Enter or a tap on a row opens its actions; lists have a back arrow and a check mark in their header,
+ swipe right goes back; lists open without a highlight until a key, the trackball or the encoder moves.
+- Menu names: Download map (Choose area, Choose region), Downloaded maps.
+
+**Trackball and touch**
+- The trackball works as a mouse pointer on every screen, with acceleration; Settings → Pointer speed
+ (Slow / Normal / Fast).
+- A click on the map shows or hides the controls; holding the button no longer opens the menu. Dragging a
+ list scrolls it.
+
+**Waypoints**
+- A sheet to name a new waypoint (hold the map, hold ⌖, P, or Menu → Add waypoint); typing replaces the
+ suggested name. All pins are the same size.
+
+**Downloads**
+- A progress pill with tiles and percentage, and a details card (tap the pill) with Pause, Resume and
+ Cancel, speed and time left.
+- Detail fetched while browsing: tiles no longer needed after you move on are dropped from the queue (the
+ one downloading finishes).
+- A download that fails (network or memory) tries again by itself after 30 s, up to 3 times; the message
+ stays 10 s.
+- A framed area is named after the nearest town's Latin-script name, so the lists can show it.
+- Framing an area a region already covers does not download it again: an unfinished region continues, a
+ finished one says so.
+
+**Fixes**
+- The map could stop responding for many seconds while big tiles loaded.
+- Zooming out kept a blank screen until tiles loaded (the previous map now stays, shrunk).
+- Browsing downloads continued after panning only after a zoom change.
+- The progress pill stayed after browsing ended.
+- A large low-zoom tile is read from the card once instead of once per zoomed-in tile.
+- Downloads failed late in a session: the large low-zoom tiles (over 1 MB) needed one free block of memory
+ that size. Tiles now download in 64 KB pieces, and the tile cache lends memory when even a piece does not fit.
+
+## 0.1.0
+
+Idle updates, named regions, waypoints, track recording and GPX files, plus smooth zoom.
+
+**Zoom**
+- + / −, I / O and double tap zoom smoothly (about a quarter of a second, around the screen centre or the
+ tapped point), then the sharp map appears. Pinch scales live as before.
+- Off on monochrome and e-paper displays, or with Settings → Smooth zoom off (renamed from Smooth pinch).
+
+**Regions and downloads**
+- Download area asks "This area" or "Choose region". Choose region lists continents, countries and regions
+ from Geofabrik's list, shipped with the app. "All of …" downloads a whole region.
+- The highest zoom level that fits OpenFreeMap's 3,000-tile / 50 MB limit is chosen automatically, with the
+ tile count, size and zoom shown before you confirm. Only tiles inside the region's outline are fetched.
+- Downloads get names: a chosen region its own name, a framed area the nearest town once downloaded. The
+ Regions screen shows the names; R renames (keyboard). Folders keep 8.3 names.
+- Outdated regions update quietly after about 30 s without input, one tile at a time, the tiles you look at
+ first, with an "Updating 42%" label. Any input pauses it and it continues later, also after a restart.
+ Only on Wi-Fi, and while charging or above 40 % battery.
+- Enter or tap on an outdated region offers "Update now". The region "browsed" is not updated.
+- HTTPS connections are kept open and shared, so a new download or browsing no longer pays a 2-3 s TLS
+ handshake each time.
+
+**Waypoints**
+- P, holding ⌖, Menu → Add waypoint, or holding the map on touch screens saves a waypoint. Named WP 001,
+ WP 002, …, shown as pins at every zoom level.
+- Tap a pin for name, coordinates and distance and direction from you. Menu → Waypoints lists them by
+ distance; Enter or tap goes there, R renames, Del or hold deletes after asking.
+- Kept in `waypoints.gpx` (GPX 1.1) in the app's folder on the card.
+
+**Tracks**
+- Menu → Record track: start, pause, resume and stop. A red ● with the time shows top right, with the
+ distance when the controls are shown.
+- Points every 5 m (or more than the GPS accuracy) or every 30 s while moving; approximate positions are
+ skipped. The track is drawn as a red line.
+- Written to `tracks/YYYYMMDD-HHMM.gpx` every 30 s, so a crash loses at most 30 s. "Recorded tracks" lists
+ the files with their folder.
+- Recording only runs while the app is open.
+
+**GPX files**
+- Copy `.gpx` files into `/sdcard/gpx`. Menu → GPX files shows or hides them.
+- Tracks blue, routes purple, waypoints as small dots. A compact `.gpc` copy is kept next to each file.
+ A 50,000-point track was tested on the PC, not yet on the device.
+
+**Trackball**
+- In mouse mode the pointer shows only while the controls or the menu are open, the button under it is
+ highlighted, and it hides after 3 s without movement.
+- A click brings it back, and a click on the map while the controls are hidden shows them.
+
+**Displays**
+- A black-and-white style for 1-bit displays: black roads with white edges, water and parks as sparse
+ patterns, outlined buildings, no grey. Checked in renders on the PC only.
+
+**Requirements**
+- Needs a board with 8 MB PSRAM for now.
+
+## 0.0.1 (proof of concept)
+
+The first public version: an offline vector map for Tactility, tested on the LilyGO T-Deck.
+
+**Map**
+- Fullscreen map drawn by the app with LVGL stopped. LVGL comes back on every exit.
+- OpenFreeMap vector tiles (OpenMapTiles schema) from the SD card: water, land cover and use, parks, buildings,
+ waterways, roads with casings, railways, boundaries and airports, anti-aliased.
+- Zoom 0-19. Levels above 14 are drawn from the z14 vectors, so they stay sharp.
+- Pan and zoom by keys (W A S D, I / O), trackball, drag, pinch, double tap and the + / − buttons.
+- Your position from Tactility's location service, with an accuracy circle, a direction arrow while moving and
+ follow mode (G or ⌖). The app never changes location settings.
+- Scale bar, and the attribution: shown in full for 5 s, then a © mark that opens it again and leads to
+ About and licences.
+- Hidden controls (☰, + / −, ⌖) that show with a tap, Space or a click, with key hints on keyboards and a
+ pointer for the trackball in mouse mode.
+- Opens at ESA ESTEC the first time, then where you left it.
+
+**Downloads**
+- Download area: a fixed frame in the middle, the map moves and zooms under it. A live tile count, size estimate
+ and the highest zoom that fits OpenFreeMap's 3,000-tile / 50 MB limit.
+- The downloader uses OpenFreeMap's TileJSON, 2 keep-alive HTTPS connections and its own User-Agent. Tiles are
+ written as `.tmp` and renamed. Downloads resume after a restart, areas share tiles, and the download stops at
+ 50 MB of real data.
+- Download while browsing: with Wi-Fi, tiles on screen that have no detail yet are fetched one at a time and kept
+ in the region "browsed".
+- Regions screen: size, date and status, with delete (tiles other regions use are kept).
+
+**Speed**
+- Prepared tiles (`.prt`) next to each `.pbf`: only what the style draws, quantised to 2048 units per tile,
+ deflated. About a fifth of the size, half the render time. Converted in idle time.
+- One I/O task for all SD card access, tiles on screen first. Two render lanes, a PSRAM cache of 32 rendered
+ tiles, and prefetch only after a second without input.
+- Cold first z14 screen in dense central Budapest: 1.8-2.0 s on the T-Deck (5.6 s in the first device build).
+
+**Known limits**
+- At most 3,000 tiles or 50 MB per download. No labels yet. Touch tested on the T-Deck only. No automatic
+ region updates yet.
diff --git a/Apps/Maps/CMakeLists.txt b/Apps/Maps/CMakeLists.txt
new file mode 100644
index 0000000..48e3904
--- /dev/null
+++ b/Apps/Maps/CMakeLists.txt
@@ -0,0 +1,13 @@
+# tactility-cmakelists-version: 1
+cmake_minimum_required(VERSION 3.20)
+
+if (NOT DEFINED ENV{TACTILITY_SDK_PATH})
+ message(FATAL_ERROR "TACTILITY_SDK_PATH environment variable is not set")
+endif()
+
+get_filename_component(TACTILITY_SDK_PATH "$ENV{TACTILITY_SDK_PATH}" ABSOLUTE)
+include("${TACTILITY_SDK_PATH}/TactilitySDK.cmake")
+
+tactility_project_pre(maps)
+project(maps)
+tactility_project_post(maps)
diff --git a/Apps/Maps/LICENSE b/Apps/Maps/LICENSE
new file mode 100644
index 0000000..94a9ed0
--- /dev/null
+++ b/Apps/Maps/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/Apps/Maps/README.md b/Apps/Maps/README.md
new file mode 100644
index 0000000..a3df056
--- /dev/null
+++ b/Apps/Maps/README.md
@@ -0,0 +1,216 @@
+# Tactility Maps
+
+Offline vector maps for [Tactility](https://github.com/TactilityProject/Tactility) devices. The device
+downloads an area from [OpenFreeMap](https://openfreemap.org/) by itself and draws it fullscreen, with
+anti-aliased roads, water, parks and buildings, from tiles on the SD card.
+
+Version 0.2.0, tested on a LilyGO T-Deck (ESP32-S3, 8 MB PSRAM). Changes are listed in
+[CHANGELOG.md](CHANGELOG.md).
+
+## Install
+
+Needs Tactility 0.8.0 or newer on an ESP32-S3 board with 8 MB PSRAM, a FAT-formatted SD card, and Wi-Fi
+for downloading maps. Tested on Tactility 0.8.0-dev (main at 0e276da2) on a LilyGO T-Deck.
+
+1. Install "Maps" from Tactility's App Hub. Or copy the `.app` file to the SD card, open it in the Files app and
+ confirm "Install".
+2. Start "Maps" from the app list. The first time it opens at ESA ESTEC in Noordwijk with no map data yet: move
+ to your area, open the menu (M or ☰) and choose Download map.
+
+## How it works
+
+- Vector tiles (OpenMapTiles schema) are stored one file per tile in `maps////.pbf` on the SD card.
+- The map stops LVGL and draws with Tactility's `pixel_buffer` API, like the built-in Terminal app.
+- Tiles are inflated and decoded as a stream, never held whole in memory, and drawn with an exact-area
+ anti-aliasing rasteriser. Two render tasks (one per core) fill a PSRAM cache. Rendered tiles can also be
+ cached on the SD card (a setting, off by default).
+- Zoom levels above 14 are drawn from the z14 vectors, so they stay sharp.
+- Each downloaded tile also gets a prepared copy next to it (`.prt`): only what the style draws, with
+ quantised coordinates, grouped by draw order. It is about a fifth of the original's size and renders faster.
+ The original `.pbf` is never changed; it keeps names and house numbers for later features.
+- One task does all SD card reads and writes, tiles on screen first. Tiles around the screen are only
+ prepared after a second without input, and any input cancels them.
+- The position comes only from Tactility's location service. The app never changes location settings.
+- Menus, lists and dialogs are drawn by the app as well, LVGL stays stopped while the app runs.
+
+## Using the app
+
+**Map screen.** The map fills the screen. Always visible: your position (●, with an accuracy
+circle), the scale bar, and the © mark.
+
+| To… | Keyboard | Trackball | Touch |
+| --- | --- | --- | --- |
+| Move the map | W A S D | Roll | Drag |
+| Zoom in / out | I / O | — | Pinch, double tap to zoom in, or the + / − buttons |
+| Show / hide controls | Space | Click on empty map | Tap the map |
+| Centre on my position and follow | G | — | ⌖ button |
+| Stop following | Move the map | Roll | Drag |
+| Turn the map | Q / E | — | Two fingers |
+| Back to north | N | — | Compass button |
+| Open a row's actions | Enter | Click | Tap the row |
+| Add a waypoint | P | — | Hold ⌖ (your position) or hold the map (that point) |
+| Menu | M | Long click | ☰ button |
+| Download an area | Shift + D | — | Menu → Download map |
+| Credits | — | Click © | Tap ©, tap again for About and licences |
+| Leave the app | Esc or Backspace | Menu → Exit | Menu → Exit |
+
+**Controls.** Hidden by default. When shown: ☰ menu top left, + and − in the middle of the right edge, ⌖
+position bottom right above the scale bar. With a keyboard each button shows its key. ⌖ is filled while
+following, an outline when not, and crossed out when there is no position. With the trackball in mouse
+mode the pointer shows only while the controls or the menu are open, the button under it is highlighted,
+and it hides after 3 s without movement (a click brings it back). The pointer works on every screen and
+speeds up with movement; Settings → Pointer speed (Slow / Normal / Fast). A click on the map shows or
+hides the controls. On boards with buttons only (no keyboard, no touch),
+the buttons move the focus through the controls and press the focused one.
+
+**Zoom.** + / −, I / O and double tap zoom smoothly (about a quarter of a second, around the screen centre,
+or the tapped point for double tap), then the sharp map appears. Pinch scales live as before. This is off
+on monochrome and e-paper displays, or with Settings → Smooth zoom off (the setting was called Smooth
+pinch).
+
+**Rotation.** Q / E turn the map by 15°, N or the compass (top right, only shown on a turned map) turns
+it back to north. Two fingers turn it once twisted past 45° (15° when the map is already turned), so
+pinching does not turn it by accident.
+⌖ / G cycles: follow north up → follow with the direction of travel up (from the GPS course while moving
+faster than about 1 m/s, smoothed, held when slower) → not following. Keys and dragging move a turned map
+in screen directions; labels stay upright; the download frame always works north up. On monochrome and
+e-paper displays turning is not animated.
+
+**Labels.** Place, street, river, water and peak names are placed for each view so they never overlap;
+street names follow the street and stay upright, with a light halo. Settings → Labels (Off / Few / Normal)
+and Settings → Latin names (the Latin-script name where the map data has one). The app ships a subset of
+Adwaita Sans with Latin, Greek, Cyrillic and Vietnamese letters, so names like Štefánikova, Łódź,
+İstanbul, Москва or Hà Nội show fully; the device makes the fonts from it once (about 7 s in the
+background on first start) and keeps them on the card. Map data downloaded with 0.1.0 is upgraded in
+place in idle time, the tiles on screen first; no download is needed.
+
+**Menu:** Download map, Downloaded maps, Waypoints, Add waypoint, Record track, GPX files, Location, Settings, About and
+licences, Exit. W/S, trackball or finger to move; Enter, click or tap to choose; Esc, Backspace or tap
+outside to close.
+
+**Lists and actions.** Enter or a tap on a row opens its actions; lists have a back arrow and a check
+mark in their header; swipe right or the back arrow goes back, and dragging a list scrolls it. Lists open
+without a highlight until a key, the trackball or the encoder moves.
+
+**Downloading:** open Download map. It asks "Choose area" or "Choose region". "Choose area" keeps the frame
+in the middle; move and zoom the map under it. The bar shows tiles, size and max zoom, and updates live.
+Enter, click or Download starts it; Esc cancels. It downloads in the background and resumes after a
+restart; areas share tiles.
+
+**Choose region** lists continents, countries and regions, from Geofabrik's region list shipped with the
+app. Enter or tap opens a region's parts; the first row "All of …" downloads the whole of it. The app
+picks the highest zoom level at which the whole region fits OpenFreeMap's limit of 3,000 tiles or 50 MB,
+and shows the tile count, size and zoom level before you confirm. Only the tiles inside the region's
+outline are fetched.
+
+**Downloaded maps:** list with name, size, date and status (complete, downloading, outdated). Select with W/S,
+trackball or tap; delete with Del then confirm, or long press then Delete; R renames (needs a keyboard);
+Esc goes back. Enter or tap on an outdated region offers "Update now". Deleting keeps tiles another
+region still uses.
+
+**Region names:** a new download gets a name: a chosen region its own name; a framed area the name of the
+nearest town in its map data once it has downloaded (before that, the smallest region of the list it lies
+in). Downloaded maps shows the names. The folder on the card keeps a short name (8.3).
+
+**Browsing:** with Wi-Fi, tiles on screen that have no detail on the card yet are downloaded while you
+look at them (a small "Downloading map" label shows) and kept in the region "browsed", so they work
+offline later. Without Wi-Fi a short message says that detail is missing here. Only the tiles on screen
+are fetched, one at a time.
+
+**Settings:** SD card cache on or off, prepared tiles deflated or raw, smooth zoom on or off, download
+while browsing on or off. Defaults: SD card cache off (switched off, its files are deleted in idle time),
+deflated, on, on. The map opens at ESA ESTEC, Noordwijk, the first time, then where you left it.
+
+**Updates:** OpenFreeMap rebuilds its map about weekly. A region downloaded from an older build shows
+"outdated" in Downloaded maps. After about 30 s without input the app updates outdated regions quietly, one tile
+at a time, the tiles you look at first; a small "Updating 42%" label shows meanwhile. Any key, touch or
+trackball movement pauses it, and it continues later, also after a restart. Only on Wi-Fi, and only while
+charging or above 40 % battery (boards that report no battery: Wi-Fi alone decides). Enter (or tap) on an
+outdated region in Downloaded maps offers "Update now". The region "browsed" (tiles fetched while browsing) is
+not updated.
+
+**Position:** comes from Tactility's location service (GPS or approximate). Menu → Location switches
+Tactility's location service on or off, with a message. If it's off, the ⌖ button is crossed out and
+pressing it says how to enable it in Tactility's settings.
+
+**Waypoints:** P, or holding ⌖, saves a waypoint at your position; Menu → Add waypoint saves one at the
+map centre; on touch screens holding the map saves one where you hold. A sheet asks for the name of a
+new waypoint; typing replaces the suggested name. Waypoints are named WP 001, WP 002, …; they show as
+pins at every zoom level, all the same size. Tap a pin for its card: name, coordinates and distance and
+direction from you. Esc closes the card. Menu → Waypoints lists them by distance: Enter or tap goes
+there, R renames (keyboard), Del or hold deletes after asking. They are kept in `waypoints.gpx` (GPX 1.1)
+in the app's folder on the card.
+
+**Track recording:** Menu → Record track → Start recording. A red ● with the time shows top right (with
+the controls shown also the distance). The same menu entry pauses, resumes and stops. Points are taken
+from the position when you moved more than 5 m (or more than the GPS accuracy), or every 30 s while
+moving; approximate positions (from the network) are skipped. The track is drawn on the map as a red
+line. It is written to `tracks/YYYYMMDD-HHMM.gpx` every 30 s, so a crash loses at most 30 s, and a file
+left open is closed the next time the app starts. Recording only runs while the app is open (background
+recording would need Tactility support; that is a question for later). "Recorded tracks" in the same menu
+lists the files with their folder, so they can be copied off the card.
+
+**GPX files:** copy `.gpx` files into the folder `gpx` at the top of the SD card (`/sdcard/gpx`). Menu →
+GPX files lists them; Enter or tap shows or hides one. Tracks are drawn blue, routes purple, waypoints
+as small dots. A file is read once and a compact copy (`.gpc`) is kept next to it, so it shows quickly
+next time. Files that are not GPX show an error and are skipped. Large tracks were tested on the PC (50,000 points
+read in about 40 ms there), not yet on the device.
+
+## OpenFreeMap's rules
+
+OpenFreeMap is free and needs no key. Its [terms of service](https://openfreemap.org/tos/) for offline downloads
+are enforced in the app:
+at most 3,000 tiles and 50 MB per download (the highest zoom level that fits is chosen, the download also
+stops at 50 MB of real data), at most 4 parallel requests (the app uses 2), and a User-Agent that names the
+app. The tile URL is read from OpenFreeMap's TileJSON. openstreetmap.org tile servers are never used.
+
+## Known limits
+
+- One download is at most 3,000 tiles or 50 MB (OpenFreeMap's terms). In dense cities that is only a few hundred
+ z14 tiles, for example the centre of Budapest. Larger areas need several separate downloads, which you start
+ yourself.
+- Labels show no characters outside Latin, Greek, Cyrillic and Vietnamese. Names in other scripts (for
+ example Japanese, Chinese or Arabic) show their Latin-script name instead, where the map data has one.
+- Heading-up needs movement (no magnetometer: the course comes from GPS).
+- Not yet tested outdoors with GPS.
+- Touch, keyboard and trackball were tested on the LilyGO T-Deck only. Other boards, boards without PSRAM and
+ monochrome or e-paper displays are untested.
+- 1-bit displays get their own black-and-white style, so far checked only in renders on the PC, not on a 1-bit device.
+- The trackball in mouse mode is only lightly tested.
+- Recording stops when the app closes (background recording would need Tactility support).
+
+## Credits
+
+- Map data: [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, available under the Open
+ Database License (ODbL).
+- Vector tile schema: [OpenMapTiles](https://openmaptiles.org/).
+- Tiles served free of charge by [OpenFreeMap](https://openfreemap.org/), see its
+ [terms](https://openfreemap.org/tos/).
+- Region outlines © OpenStreetMap contributors, from Geofabrik GmbH (download.geofabrik.de), ODbL 1.0.
+- Built for [Tactility](https://github.com/TactilityProject/Tactility).
+- Label font: Adwaita Sans, under the SIL Open Font License 1.1; see [THIRD_PARTY.md](THIRD_PARTY.md).
+
+## Attribution
+
+The full "© OpenMapTiles © OpenStreetMap contributors" shows for 5 seconds when the map opens and then
+collapses to a small "©" mark in the bottom left corner. Tapping or clicking it shows the text again for 5
+seconds, tapping the text opens "About and licences", which is also in the menu. This follows the OSMF
+attribution guidelines for small screens.
+
+## Building
+
+Needs ESP-IDF 6.1 and the Tactility SDK matching the firmware on the device.
+
+```
+python /tt.py app build
+python /tt.py app install --host
+python /tt.py app run --host
+```
+
+The files in `assets/` are made by the scripts in `tools/`: `make_label_font.py` makes the label font subset
+`assets/fonts/LabelSans.ttf` (needs fontTools), `build_catalog.py` makes the region catalog `assets/regions.bin`.
+
+## Licence
+
+GPL-3.0-or-later, the same as Tactility; see [LICENSE](LICENSE). Third-party code and data are listed in [THIRD_PARTY.md](THIRD_PARTY.md). No code was copied from
+the projects listed as prior art in the plan.
diff --git a/Apps/Maps/THIRD_PARTY.md b/Apps/Maps/THIRD_PARTY.md
new file mode 100644
index 0000000..d4c6654
--- /dev/null
+++ b/Apps/Maps/THIRD_PARTY.md
@@ -0,0 +1,12 @@
+# Third-party code
+
+| Component | Where | Licence | Notes |
+|---|---|---|---|
+| Adwaita Sans (GNOME [adwaita-fonts](https://github.com/GNOME/adwaita-fonts), derived from Inter by the Inter Project Authors) | `assets/fonts/LabelSans.ttf` | SIL Open Font License 1.1, https://raw.githubusercontent.com/GNOME/adwaita-fonts/refs/heads/main/LICENSE | A subset (U+0020-007E, U+00A0-024F Latin-1 and Latin Extended-A/-B, U+0259, U+02BB-02BC, U+02C6-02DD, U+0300-036F combining marks, U+0370-03FF Greek, U+0400-052F Cyrillic, U+1E00-1EFF Latin Extended Additional, U+2010-2027, U+2030-203A, U+20AC, U+2116, weight 550, no hinting or layout tables) made by `tools/make_label_font.py` from the font in Tactility's repository. The app makes its map label fonts from it on the device. The name table of the file names the licence. |
+
+`main/Source/app/RootCertificates.h` holds the public root certificates of Google Trust Services (GTS Root R1, R4)
+and Let's Encrypt (ISRG Root X1, X2), as published at pki.goog and letsencrypt.org, used to verify the HTTPS
+connection to OpenFreeMap.
+
+The rasteriser in `main/Source/render/Raster.cpp` is written for this project. It uses the published signed-area
+coverage technique (as described for font-rs and stb_truetype), no code was copied.
diff --git a/Apps/Maps/assets/fonts/LabelSans.ttf b/Apps/Maps/assets/fonts/LabelSans.ttf
new file mode 100644
index 0000000..c09b0bd
Binary files /dev/null and b/Apps/Maps/assets/fonts/LabelSans.ttf differ
diff --git a/Apps/Maps/assets/regions.bin b/Apps/Maps/assets/regions.bin
new file mode 100644
index 0000000..8afaac4
Binary files /dev/null and b/Apps/Maps/assets/regions.bin differ
diff --git a/Apps/Maps/main/CMakeLists.txt b/Apps/Maps/main/CMakeLists.txt
new file mode 100644
index 0000000..b11441b
--- /dev/null
+++ b/Apps/Maps/main/CMakeLists.txt
@@ -0,0 +1,8 @@
+include("$ENV{TACTILITY_SDK_PATH}/TactilitySDK.cmake")
+
+file(GLOB_RECURSE SOURCE_FILES Source/*.c*)
+tactility_component_register(
+ SRCS ${SOURCE_FILES}
+ REQUIRES graphics-module binfont-module location-module http-module esp_http_client esp_rom mbedtls
+)
+target_compile_options(${COMPONENT_LIB} PRIVATE -O2 -Wno-format-truncation)
diff --git a/Apps/Maps/main/Source/app/BinFontText.cpp b/Apps/Maps/main/Source/app/BinFontText.cpp
new file mode 100644
index 0000000..667d684
--- /dev/null
+++ b/Apps/Maps/main/Source/app/BinFontText.cpp
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "BinFontText.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+struct FontSearch {
+ const char* prefix;
+ char name[96];
+};
+
+bool findFont(const char* name, bool isDirectory, void* context) {
+ auto* search = static_cast(context);
+ if (!isDirectory && strncmp(name, search->prefix, strlen(search->prefix)) == 0) {
+ snprintf(search->name, sizeof(search->name), "%s", name);
+ return false;
+ }
+ return true;
+}
+
+/** Decodes one UTF-8 character and advances the pointer. */
+uint32_t nextCodepoint(const char*& text) {
+ const auto first = static_cast(*text++);
+ if (first < 0x80) {
+ return first;
+ }
+ if ((first & 0xE0) == 0xC0 && *text != '\0') {
+ return ((first & 0x1Fu) << 6) | (static_cast(*text++) & 0x3Fu);
+ }
+ // Longer sequences are skipped and shown as '?'
+ while ((static_cast(*text) & 0xC0) == 0x80) {
+ text++;
+ }
+ return '?';
+}
+
+} // namespace
+
+bool BinFontText::open(const char* folder, const char* const* prefixes, int prefixCount) {
+ for (int i = 0; i < prefixCount; i++) {
+ FontSearch search { prefixes[i], "" };
+ listDirectory(folder, findFont, &search);
+ if (search.name[0] == '\0') {
+ continue;
+ }
+ char path[260];
+ snprintf(path, sizeof(path), "%s/%s", folder, search.name);
+ if (binfont_open_file(path, &font) == ERROR_NONE) {
+ owned = true;
+ BinFontMetrics metrics;
+ binfont_get_metrics(font, &metrics);
+ height = metrics.line_height;
+ ascentPixels = metrics.ascent;
+ logInfo("Font %s, line height %d", search.name, height);
+ return true;
+ }
+ font = nullptr;
+ }
+ return false;
+}
+
+bool BinFontText::openFile(const char* path) {
+ close();
+ if (binfont_open_file(path, &font) != ERROR_NONE) {
+ font = nullptr;
+ return false;
+ }
+ owned = true;
+ BinFontMetrics metrics;
+ binfont_get_metrics(font, &metrics);
+ height = metrics.line_height;
+ ascentPixels = metrics.ascent;
+ logInfo("Font file %s, line height %d", path, height);
+ return true;
+}
+
+bool BinFontText::borrow(int lvglFontSize) {
+ const lv_font_t* lvFont = lvgl_get_text_font(static_cast(lvglFontSize));
+ // Tactility's fonts come from lvgl_binfont_create(), which keeps the BinFont in dsc. Without a registered font
+ // LVGL's built-in one comes back, whose dsc is something else.
+ if (lvFont == nullptr || lvFont->dsc == nullptr || lvFont->get_glyph_dsc == lv_font_get_glyph_dsc_fmt_txt) {
+ return false;
+ }
+ font = static_cast(const_cast(lvFont->dsc));
+ owned = false;
+ BinFontMetrics metrics;
+ binfont_get_metrics(font, &metrics);
+ height = metrics.line_height;
+ ascentPixels = metrics.ascent;
+ logInfo("Font from the system, size %d, line height %d", lvglFontSize, height);
+ return true;
+}
+
+void BinFontText::close() {
+ if (target != nullptr) {
+ pixel_buffer_free(target);
+ target = nullptr;
+ targetPixels = nullptr;
+ }
+ if (font != nullptr && owned) {
+ binfont_close(font);
+ }
+ font = nullptr;
+ owned = false;
+}
+
+int BinFontText::textWidth(const char* text) {
+ if (font == nullptr) {
+ return 0;
+ }
+ // The same advances and kerning binfont_draw_text_rgb565() applies
+ int32_t widthX16 = 0;
+ uint32_t previous = 0;
+ bool hasPrevious = false;
+ while (*text != '\0') {
+ BinFontGlyph glyph;
+ if (!binfont_get_glyph(font, nextCodepoint(text), &glyph)) {
+ continue;
+ }
+ if (hasPrevious) {
+ widthX16 += binfont_get_kerning_x16(font, previous, glyph.glyph_id);
+ }
+ widthX16 += static_cast(glyph.advance_x16);
+ previous = glyph.glyph_id;
+ hasPrevious = true;
+ }
+ return (widthX16 + 8) >> 4;
+}
+
+void BinFontText::drawText(Canvas& canvas, int x, int y, const char* text, uint16_t color) {
+ if (font == nullptr) {
+ return;
+ }
+ if (target == nullptr || targetPixels != canvas.pixels) {
+ if (target != nullptr) {
+ pixel_buffer_free(target);
+ }
+ target = pixel_buffer_wrap(canvas.pixels, DISPLAY_COLOR_FORMAT_RGB565, canvas.width, canvas.height, 0);
+ targetPixels = target != nullptr ? canvas.pixels : nullptr;
+ if (target == nullptr) {
+ return;
+ }
+ }
+ binfont_draw_text_rgb565(target, x, y + ascentPixels, font, text, color, PIXEL_BUFFER_CONVERSION_AVERAGE);
+}
+
+bool BinFontText::glyph(uint32_t codepoint, LabelGlyph& out, bool withMask) {
+ BinFontGlyph found;
+ if (font == nullptr || !binfont_get_glyph(font, codepoint, &found)) {
+ return false;
+ }
+ out.id = found.glyph_id;
+ out.advanceX16 = static_cast(found.advance_x16);
+ out.left = found.x;
+ // binfont gives the box's bottom above the baseline, labels want its top below it
+ out.top = -(found.y + found.height);
+ out.width = found.width;
+ out.height = found.height;
+ out.alpha = nullptr;
+ if (withMask && found.width > 0 && found.height > 0 && found.width * found.height <= MASK_BYTES &&
+ binfont_get_glyph_bitmap(font, &found, mask, found.width) == ERROR_NONE) {
+ out.alpha = mask;
+ }
+ return true;
+}
+
+int BinFontText::kerningX16(uint32_t leftId, uint32_t rightId) {
+ return font != nullptr ? binfont_get_kerning_x16(font, leftId, rightId) : 0;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/BinFontText.h b/Apps/Maps/main/Source/app/BinFontText.h
new file mode 100644
index 0000000..627a24f
--- /dev/null
+++ b/Apps/Maps/main/Source/app/BinFontText.h
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "../render/LabelLayout.h"
+#include "../ui/Canvas.h"
+
+#include
+
+struct BinFont;
+struct PixelBuffer;
+
+namespace maps {
+
+/**
+ * Text in one of Tactility's cached fonts (/cache/fonts), drawn with the binfont-module. As a LabelFont it gives
+ * glyph masks for map labels.
+ */
+class BinFontText final : public Font, public LabelFont {
+public:
+ ~BinFontText() override { close(); }
+
+ /**
+ * Opens the first font whose file name starts with one of the prefixes. Call it on the I/O task: the binfont-module
+ * loads the whole file into PSRAM, so drawing never reads the card.
+ */
+ bool open(const char* folder, const char* const* prefixes, int prefixCount);
+ /** Opens one font file (read whole into PSRAM, so on the I/O task). */
+ bool openFile(const char* path);
+ /**
+ * Uses the text font Tactility's UI uses (an LvglFontSize), without a copy. Firmware builds with the fonts inside
+ * have no font files on the card, so this comes first. Fails when that font is not a BinFont.
+ */
+ bool borrow(int lvglFontSize);
+ void close();
+ bool isOpen() const { return font != nullptr; }
+
+ int lineHeight() const override { return font != nullptr ? height : 10; }
+ int ascent() const override { return ascentPixels; }
+ bool glyph(uint32_t codepoint, LabelGlyph& out, bool withMask) override;
+ int kerningX16(uint32_t leftId, uint32_t rightId) override;
+ int textWidth(const char* text) override;
+ void drawText(Canvas& canvas, int x, int y, const char* text, uint16_t color) override;
+
+private:
+ BinFont* font = nullptr;
+ /** Borrowed fonts belong to Tactility and are not closed. */
+ bool owned = false;
+ int height = 0;
+ int ascentPixels = 0;
+ /** The mask of the last glyph asked for with withMask; labels use fonts of at most about 30 px. */
+ static constexpr int MASK_BYTES = 48 * 48;
+ uint8_t mask[MASK_BYTES] {};
+ /** The canvas the text was last drawn on, wrapped once as a PixelBuffer. */
+ PixelBuffer* target = nullptr;
+ uint16_t* targetPixels = nullptr;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Debug.cpp b/Apps/Maps/main/Source/app/Debug.cpp
new file mode 100644
index 0000000..f48ae23
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Debug.cpp
@@ -0,0 +1,144 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "Debug.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+void writeLittleEndian(uint8_t* out, uint32_t value, int bytes) {
+ for (int i = 0; i < bytes; i++) {
+ out[i] = static_cast(value >> (8 * i));
+ }
+}
+
+} // namespace
+
+bool saveBmp(const char* path, const uint16_t* pixels, int width, int height) {
+ FILE* file = fopen(path, "wb");
+ if (file == nullptr) {
+ return false;
+ }
+ const int rowBytes = (width * 3 + 3) & ~3;
+ uint8_t header[54] = { 'B', 'M' };
+ writeLittleEndian(header + 2, 54 + rowBytes * height, 4);
+ writeLittleEndian(header + 10, 54, 4);
+ writeLittleEndian(header + 14, 40, 4);
+ writeLittleEndian(header + 18, width, 4);
+ writeLittleEndian(header + 22, height, 4);
+ writeLittleEndian(header + 26, 1, 2);
+ writeLittleEndian(header + 28, 24, 2);
+ bool ok = fwrite(header, 1, sizeof(header), file) == sizeof(header);
+ auto* row = static_cast(largeAlloc(rowBytes));
+ if (row == nullptr) {
+ fclose(file);
+ return false;
+ }
+ memset(row, 0, rowBytes);
+ for (int y = height - 1; y >= 0 && ok; y--) {
+ for (int x = 0; x < width; x++) {
+ const uint16_t c = pixels[y * width + x];
+ row[x * 3 + 0] = static_cast((c & 0x1F) * 255 / 31);
+ row[x * 3 + 1] = static_cast(((c >> 5) & 0x3F) * 255 / 63);
+ row[x * 3 + 2] = static_cast(((c >> 11) & 0x1F) * 255 / 31);
+ }
+ ok = fwrite(row, 1, rowBytes, file) == static_cast(rowBytes);
+ }
+ largeFree(row);
+ fclose(file);
+ return ok;
+}
+
+DebugScript::~DebugScript() {
+ largeFree(text);
+}
+
+bool DebugScript::open(const char* root) {
+ char path[160];
+ snprintf(path, sizeof(path), "%s/debug.txt", root);
+ FILE* file = fopen(path, "r");
+ if (file == nullptr) {
+ return false;
+ }
+ constexpr size_t MAX_SCRIPT = 128 * 1024;
+ text = static_cast(largeAlloc(MAX_SCRIPT));
+ if (text != nullptr) {
+ const size_t length = fread(text, 1, MAX_SCRIPT - 1, file);
+ text[length] = '\0';
+ position = 0;
+ logInfo("Playing %s", path);
+ }
+ fclose(file);
+ return text != nullptr;
+}
+
+DebugScript::Step DebugScript::next(uint32_t now) {
+ Step step {};
+ step.action = Action::None;
+ if (text == nullptr || static_cast(now - resumeAt) < 0) {
+ return step;
+ }
+ char line[96];
+ while (text[position] != '\0') {
+ size_t length = 0;
+ while (text[position] != '\0' && text[position] != '\n') {
+ if (length + 1 < sizeof(line)) {
+ line[length++] = text[position];
+ }
+ position++;
+ }
+ line[length] = '\0';
+ if (text[position] == '\n') {
+ position++;
+ }
+ char command[16] = "";
+ char argument[48] = "";
+ if (sscanf(line, "%15s %47[^\r\n]", command, argument) < 1 || command[0] == '#') {
+ continue;
+ }
+ logInfo("debug: %s %s", command, argument);
+ if (strcmp(command, "wait") == 0) {
+ resumeAt = now + static_cast(atoi(argument));
+ return step;
+ }
+ if (strcmp(command, "key") == 0) {
+ step.action = Action::Key;
+ if (strcmp(argument, "esc") == 0) {
+ step.key = 0x1B;
+ } else if (strcmp(argument, "enter") == 0) {
+ step.key = 10;
+ } else if (strcmp(argument, "bs") == 0) {
+ step.key = '\b';
+ } else {
+ step.key = static_cast(argument[0]);
+ }
+ return step;
+ }
+ if (strcmp(command, "shot") == 0) {
+ step.action = Action::Shot;
+ snprintf(step.name, sizeof(step.name), "%s", argument);
+ return step;
+ }
+ if (strcmp(command, "goto") == 0 && sscanf(argument, "%lf %lf %d", &step.lat, &step.lon, &step.zoom) == 3) {
+ step.action = Action::Goto;
+ return step;
+ }
+ if (strcmp(command, "idle") == 0) {
+ step.action = Action::WaitIdle;
+ return step;
+ }
+ if (strcmp(command, "exit") == 0) {
+ step.action = Action::Exit;
+ return step;
+ }
+ }
+ largeFree(text);
+ text = nullptr;
+ return step;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Debug.h b/Apps/Maps/main/Source/app/Debug.h
new file mode 100644
index 0000000..5604f8f
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Debug.h
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+#include
+
+namespace maps {
+
+/** Writes an RGB565 frame as a 24-bit BMP file, for screenshots while LVGL is stopped. */
+bool saveBmp(const char* path, const uint16_t* pixels, int width, int height);
+
+/**
+ * Plays /debug.txt when it exists, so the app can be tested without touching the device.
+ * Lines: "key ", "wait ", "shot ", "goto ", "idle" (waits until the view is complete), "exit".
+ */
+class DebugScript {
+public:
+ enum class Action {
+ None,
+ Key,
+ Shot,
+ Goto,
+ WaitIdle,
+ Exit
+ };
+
+ struct Step {
+ Action action;
+ uint32_t key;
+ char name[32];
+ double lat;
+ double lon;
+ int zoom;
+ };
+
+ ~DebugScript();
+ /** Reads the whole script into memory. Call it on the I/O task. */
+ bool open(const char* root);
+ bool isActive() const { return text != nullptr; }
+ /** Returns the next step once its wait is over, or Action::None. */
+ Step next(uint32_t now);
+
+private:
+ char* text = nullptr;
+ size_t position = 0;
+ uint32_t resumeAt = 0;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/DisplayOutput.cpp b/Apps/Maps/main/Source/app/DisplayOutput.cpp
new file mode 100644
index 0000000..6cc16b2
--- /dev/null
+++ b/Apps/Maps/main/Source/app/DisplayOutput.cpp
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "DisplayOutput.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+
+#include
+
+namespace maps {
+
+namespace {
+
+/**
+ * Strips are in internal DMA memory. In PSRAM the SPI driver copied every strip into a new internal DMA buffer of the
+ * same size, which failed while a download used most of the internal RAM, and the map flickered.
+ */
+constexpr int STRIP_ROWS = 12;
+
+} // namespace
+
+DisplayOutput::~DisplayOutput() {
+ end();
+}
+
+bool DisplayOutput::begin(Device* displayDevice) {
+ display = displayDevice;
+ panelWidth = display_get_resolution_x(display);
+ panelHeight = display_get_resolution_y(display);
+ format = display_get_color_format(display);
+ monochrome = format == DISPLAY_COLOR_FORMAT_MONOCHROME || format == DISPLAY_COLOR_FORMAT_GRAYSCALE8;
+ fullFrame = format == DISPLAY_COLOR_FORMAT_MONOCHROME || display_has_capability(display, DISPLAY_CAPABILITY_REQUIRES_FULL_FRAME);
+
+ if (display_get_frame_buffer_count(display) == 2) {
+ void* first = nullptr;
+ void* second = nullptr;
+ display_get_frame_buffer(display, 0, &first);
+ display_get_frame_buffer(display, 1, &second);
+ if (first != nullptr && second != nullptr) {
+ hwBuffers[0] = pixel_buffer_wrap(first, format, panelWidth, panelHeight, 0);
+ hwBuffers[1] = pixel_buffer_wrap(second, format, panelWidth, panelHeight, 0);
+ }
+ if (hwBuffers[0] != nullptr && hwBuffers[1] != nullptr) {
+ logInfo("Display %dx%d format %d, hardware double buffer", panelWidth, panelHeight, format);
+ return true;
+ }
+ pixel_buffer_free(hwBuffers[0]);
+ pixel_buffer_free(hwBuffers[1]);
+ hwBuffers[0] = nullptr;
+ hwBuffers[1] = nullptr;
+ }
+
+ if (fullFrame) {
+ frameBuffer = pixel_buffer_create(format, panelWidth, panelHeight);
+ logInfo("Display %dx%d format %d, full frames", panelWidth, panelHeight, format);
+ return frameBuffer != nullptr;
+ }
+
+ const MemoryPolicy stripPolicy = { .required = MEMORY_CAPABILITY_INTERNAL | MEMORY_CAPABILITY_DMA, .desired = 0, .alignment = 64 };
+ strips[0] = pixel_buffer_create_with_policy(format, panelWidth, STRIP_ROWS, &stripPolicy);
+ strips[1] = pixel_buffer_create_with_policy(format, panelWidth, STRIP_ROWS, &stripPolicy);
+ logInfo("Display %dx%d format %d, strips of %d rows", panelWidth, panelHeight, format, STRIP_ROWS);
+ return strips[0] != nullptr && strips[1] != nullptr;
+}
+
+void DisplayOutput::end() {
+ for (auto*& buffer : hwBuffers) {
+ pixel_buffer_free(buffer);
+ buffer = nullptr;
+ }
+ for (auto*& buffer : strips) {
+ pixel_buffer_free(buffer);
+ buffer = nullptr;
+ }
+ pixel_buffer_free(frameBuffer);
+ frameBuffer = nullptr;
+ display = nullptr;
+}
+
+void DisplayOutput::present(uint16_t* frame, int y, int height) {
+ if (display == nullptr) {
+ return;
+ }
+ PixelBuffer* source = pixel_buffer_wrap(frame, DISPLAY_COLOR_FORMAT_RGB565, panelWidth, panelHeight, 0);
+ if (source == nullptr) {
+ return;
+ }
+ const auto conversion = monochrome ? PIXEL_BUFFER_CONVERSION_LUMA_THRESHOLD : PIXEL_BUFFER_CONVERSION_AVERAGE;
+
+ if (hwBuffers[0] != nullptr) {
+ // The whole frame goes to the back buffer, so it never shows an older frame in the other region
+ PixelBuffer* back = hwBuffers[backBuffer];
+ pixel_buffer_blit(back, 0, 0, source, 0, 0, panelWidth, panelHeight, conversion);
+ pixel_buffer_msync(back, 0, 0, panelWidth, panelHeight);
+ display_draw_bitmap(display, 0, 0, panelWidth, panelHeight, pixel_buffer_get_data(back));
+ backBuffer = 1 - backBuffer;
+ } else if (frameBuffer != nullptr) {
+ pixel_buffer_blit(frameBuffer, 0, y, source, 0, y, panelWidth, height, conversion);
+ pixel_buffer_msync(frameBuffer, 0, 0, panelWidth, panelHeight);
+ display_draw_bitmap(display, 0, 0, panelWidth, panelHeight, pixel_buffer_get_data(frameBuffer));
+ } else {
+ for (int row = y; row < y + height; row += STRIP_ROWS) {
+ const int rows = std::min(STRIP_ROWS, y + height - row);
+ PixelBuffer* strip = strips[nextStrip];
+ nextStrip = 1 - nextStrip;
+ pixel_buffer_blit(strip, 0, 0, source, 0, row, panelWidth, rows, conversion);
+ pixel_buffer_msync(strip, 0, 0, panelWidth, rows);
+ display_draw_bitmap(display, 0, row, panelWidth, row + rows, pixel_buffer_get_data(strip));
+ }
+ }
+ pixel_buffer_free(source);
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/DisplayOutput.h b/Apps/Maps/main/Source/app/DisplayOutput.h
new file mode 100644
index 0000000..eddfdef
--- /dev/null
+++ b/Apps/Maps/main/Source/app/DisplayOutput.h
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+#include
+
+#include
+
+struct PixelBuffer;
+
+namespace maps {
+
+/**
+ * Sends an RGB565 frame to the panel in its own colour format. Uses the hardware double buffer when the driver
+ * has one, a full frame buffer when the panel needs full frames, and otherwise small strips.
+ */
+class DisplayOutput {
+public:
+ ~DisplayOutput();
+
+ bool begin(Device* displayDevice);
+ void end();
+
+ /** Sends rows y to y + height of a frame of width() x height() RGB565 pixels. */
+ void present(uint16_t* frame, int y, int height);
+
+ int width() const { return panelWidth; }
+ int height() const { return panelHeight; }
+ bool isMonochrome() const { return monochrome; }
+
+private:
+ Device* display = nullptr;
+ int panelWidth = 0;
+ int panelHeight = 0;
+ DisplayColorFormat format = DISPLAY_COLOR_FORMAT_RGB565;
+ bool monochrome = false;
+ bool fullFrame = false;
+ PixelBuffer* hwBuffers[2] = { nullptr, nullptr };
+ int backBuffer = 1;
+ PixelBuffer* frameBuffer = nullptr;
+ PixelBuffer* strips[2] = { nullptr, nullptr };
+ int nextStrip = 0;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Downloader.cpp b/Apps/Maps/main/Source/app/Downloader.cpp
new file mode 100644
index 0000000..31b3170
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Downloader.cpp
@@ -0,0 +1,1689 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "Downloader.h"
+#include "RootCertificates.h"
+
+#include "../platform/Platform.h"
+#include "../mvt/PlaceFinder.h"
+#include "../tiles/RegionCatalog.h"
+#include "../tiles/TileJson.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+constexpr uint32_t CONTROL_STACK = 8192;
+constexpr uint32_t CONNECTION_STACK = 6144;
+constexpr UBaseType_t TASK_PRIORITY = 3;
+/** Tiles at this zoom level and below are downloaded one at a time. */
+constexpr int ALONE_MAX_ZOOM = 7;
+/** Idle keep-alive connections are closed after this long, a new one costs a full TLS handshake. */
+constexpr uint32_t IDLE_CLOSE_MS = 60000;
+/** How often the network tasks look for idle connections to close. */
+constexpr TickType_t SWEEP_TICKS = pdMS_TO_TICKS(5000);
+
+/**
+ * Starts a network task with its stack in PSRAM, which keeps about 28 KB of internal RAM free for Wi-Fi, TLS and
+ * the display. These tasks never touch flash or the SD card themselves. The task ends by suspending itself after
+ * signalling its owner, who then deletes it with endNetworkTask().
+ */
+bool startNetworkTask(TaskFunction_t function, const char* name, uint32_t stack, void* context, TaskHandle_t* task) {
+ return xTaskCreatePinnedToCoreWithCaps(function, name, stack, context, TASK_PRIORITY, task, Downloader::NETWORK_CORE, MALLOC_CAP_SPIRAM) == pdPASS;
+}
+
+void endNetworkTask(TaskHandle_t task) {
+ // It signalled before suspending itself, wait until it got there
+ TaskStatus_t status {};
+ for (int i = 0; i < 100; i++) {
+ vTaskGetInfo(task, &status, pdFALSE, eInvalid);
+ if (status.eCurrentState == eSuspended) {
+ break;
+ }
+ vTaskDelay(pdMS_TO_TICKS(5));
+ }
+ vTaskDeleteWithCaps(task);
+}
+constexpr int HTTP_TIMEOUT_MS = 15000;
+constexpr int SAVE_EVERY_TILES = 20;
+/** How often idle time looks for regions that need an update. */
+constexpr uint32_t UPDATE_CHECK_MS = 15000;
+constexpr int MAX_ATTEMPTS = 3;
+
+bool wifiConnected() {
+ Device* wifi = nullptr;
+ if (device_get_first_active_by_type(&WIFI_TYPE, &wifi) != ERROR_NONE) {
+ return false;
+ }
+ WifiStationState state = WIFI_STATION_STATE_DISCONNECTED;
+ const bool connected = wifi_get_station_state(wifi, &state) == ERROR_NONE && state == WIFI_STATION_STATE_CONNECTED;
+ device_put(wifi);
+ return connected;
+}
+
+/** Larger responses are refused, no real tile comes close. */
+constexpr size_t MAX_TILE_BYTES = 4 * 1024 * 1024;
+
+/**
+ * When the last connection was established (TCP and TLS done), in ms since boot, for the timing log. 32 bits:
+ * 64-bit atomics are not exported to apps on the ESP32-S3.
+ */
+std::atomic connectedAtMs = 0;
+
+uint32_t msSince(int64_t startUs, int64_t endUs) {
+ return static_cast((endUs - startUs) / 1000);
+}
+
+/** TLS connections opened in this app session and the time they took, for the log. */
+std::atomic handshakeCount = 0;
+std::atomic handshakeTotalMs = 0;
+
+} // namespace
+
+std::atomic Downloader::handshakes = 0;
+
+esp_err_t Downloader::onHttpEvent(esp_http_client_event_t* event) {
+ auto* connection = static_cast(event->user_data);
+ if (event->event_id == HTTP_EVENT_ON_CONNECTED) {
+ connectedAtMs = static_cast(esp_timer_get_time() / 1000);
+ if (connection != nullptr) {
+ connection->connectMs = connectedAtMs - connection->requestStartMs;
+ connection->connected = true;
+ if (connection->owner != nullptr) {
+ connection->owner->endHandshake(*connection);
+ }
+ }
+ }
+ if (event->event_id == HTTP_EVENT_DISCONNECTED && connection != nullptr) {
+ connection->connected = false;
+ }
+ ChunkedBody* body = connection != nullptr ? connection->body : nullptr;
+ // Low-zoom tiles are up to about 1.2 MB, kept in 64 KB pieces: late in a session PSRAM has the room, but not in
+ // one block
+ if (event->event_id == HTTP_EVENT_ON_DATA && body != nullptr) {
+ const size_t total = body->size() + static_cast(event->data_len);
+ if (total > MAX_TILE_BYTES) {
+ body->fail();
+ } else {
+ Downloader* owner = connection->owner;
+ if (!body->failed() && !body->ensure(total) && owner != nullptr && owner->memoryRelief != nullptr) {
+ // No room even for a piece: the map gives some of its tile cache for it
+ owner->memoryRelief(owner->memoryReliefContext, ChunkedBody::CHUNK_BYTES);
+ }
+ body->putBytes(event->data, static_cast(event->data_len));
+ }
+ }
+ return ESP_OK;
+}
+
+void* Downloader::createClient(Connection* connection) {
+ esp_http_client_config_t config = {};
+ config.url = TILEJSON_URL;
+ config.user_agent = USER_AGENT;
+ config.timeout_ms = HTTP_TIMEOUT_MS;
+ config.keep_alive_enable = true;
+ config.cert_pem = ROOT_CERTIFICATES;
+ config.cert_len = sizeof(ROOT_CERTIFICATES);
+ config.event_handler = onHttpEvent;
+ config.user_data = connection;
+ config.buffer_size = 4096;
+ esp_http_client_handle_t client = esp_http_client_init(&config);
+ if (client != nullptr) {
+ // Tiles are stored as the server compressed them
+ esp_http_client_set_header(client, "Accept-Encoding", "gzip");
+ }
+ if (connection != nullptr) {
+ connection->connected = false;
+ }
+ return client;
+}
+
+void Downloader::beginHandshake(Connection& connection) {
+ // One handshake at a time: each is 2-3.6 s of CPU on the network core, two at once kept its idle task from the
+ // task watchdog
+ xSemaphoreTake(handshakeLock, pdMS_TO_TICKS(HTTP_TIMEOUT_MS));
+ connection.handshaking = true;
+ handshakes++;
+}
+
+void Downloader::endHandshake(Connection& connection) {
+ if (connection.handshaking) {
+ connection.handshaking = false;
+ handshakes--;
+ xSemaphoreGive(handshakeLock);
+ }
+}
+
+esp_err_t Downloader::perform(Connection& connection) {
+ auto* client = static_cast(connection.client);
+ const bool wasConnected = connection.connected;
+ if (!wasConnected) {
+ beginHandshake(connection);
+ }
+ connection.requestStartMs = static_cast(esp_timer_get_time() / 1000);
+ connection.connectMs = 0;
+ const esp_err_t result = esp_http_client_perform(client);
+ endHandshake(connection);
+ if (connection.connectMs > 0) {
+ const uint32_t count = ++handshakeCount;
+ const uint32_t total = handshakeTotalMs += connection.connectMs;
+ logInfo("TLS: [%d] new connection in %u ms (session: %u connections, %u ms)", connection.index,
+ static_cast(connection.connectMs), static_cast(count), static_cast(total));
+ }
+ return result;
+}
+
+void Downloader::takeClient(Connection& connection) {
+ if (connection.client != nullptr) {
+ return;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ if (idleCount > 0) {
+ // The most recently used one, the least likely to have been closed by the server
+ idleCount--;
+ connection.client = idleClients[idleCount].client;
+ connection.connected = true;
+ }
+ xSemaphoreGive(mutex);
+ if (connection.client != nullptr) {
+ esp_http_client_set_user_data(static_cast(connection.client), &connection);
+ } else {
+ connection.client = createClient(&connection);
+ }
+}
+
+void Downloader::dropClient(Connection& connection) {
+ if (connection.client != nullptr) {
+ esp_http_client_cleanup(static_cast(connection.client));
+ connection.client = nullptr;
+ }
+ connection.connected = false;
+}
+
+void Downloader::giveClient(Connection& connection) {
+ if (connection.client == nullptr) {
+ return;
+ }
+ if (!connection.connected) {
+ dropClient(connection);
+ return;
+ }
+ esp_http_client_set_user_data(static_cast(connection.client), nullptr);
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ void* surplus = nullptr;
+ if (idleCount < IDLE_CLIENTS) {
+ idleClients[idleCount++] = { connection.client, nowMs() };
+ } else {
+ surplus = connection.client;
+ }
+ xSemaphoreGive(mutex);
+ if (surplus != nullptr) {
+ esp_http_client_cleanup(static_cast(surplus));
+ }
+ connection.client = nullptr;
+ connection.connected = false;
+}
+
+void Downloader::closeIdleClients(uint32_t maxIdleMs) {
+ void* closing[IDLE_CLIENTS] {};
+ int closingCount = 0;
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const uint32_t now = nowMs();
+ int kept = 0;
+ for (int i = 0; i < idleCount; i++) {
+ if (now - idleClients[i].sinceMs >= maxIdleMs) {
+ closing[closingCount++] = idleClients[i].client;
+ } else {
+ idleClients[kept++] = idleClients[i];
+ }
+ }
+ idleCount = kept;
+ xSemaphoreGive(mutex);
+ for (int i = 0; i < closingCount; i++) {
+ esp_http_client_cleanup(static_cast(closing[i]));
+ }
+ if (closingCount > 0) {
+ logInfo("TLS: closed %d idle connection(s)", closingCount);
+ }
+}
+
+namespace {
+
+void makeFolders(const char* root, const char* region, const TileKey& tile) {
+ char path[PATH_MAX_LENGTH];
+ snprintf(path, sizeof(path), "%s/maps/%s/%d", root, region, tile.z);
+ mkdir(path, 0777);
+ if (tileFolderPath(path, sizeof(path), root, region, tile)) {
+ mkdir(path, 0777);
+ }
+}
+
+struct MetaJob {
+ char path[PATH_MAX_LENGTH];
+ RegionMeta* meta;
+};
+
+bool readMetaJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ return readRegionMeta(job->path, *job->meta);
+}
+
+bool writeMetaJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ return writeRegionMeta(job->path, *job->meta);
+}
+
+struct OnCardJob {
+ const char* root;
+ const TileStore* store;
+ const char* region;
+ TileKey tile;
+ bool inThisRegion;
+};
+
+bool onCardJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ char path[PATH_MAX_LENGTH];
+ job->inThisRegion = tilePath(path, sizeof(path), job->root, job->region, job->tile) && fileExists(path);
+ if (job->inThisRegion) {
+ return true;
+ }
+ // A tile of an overlapping region is shared instead of downloaded again
+ for (int i = 0; i < job->store->regionCount(); i++) {
+ const char* other = job->store->regionName(i);
+ if (strcmp(other, job->region) != 0 && tilePath(path, sizeof(path), job->root, other, job->tile) && fileExists(path)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+struct StoreTileJob {
+ const char* root;
+ const char* region;
+ TileKey tile;
+ const ChunkedBody* body;
+ /** The tile column folder this connection made last, so it is not made again for every tile. */
+ char* lastFolder;
+ bool yielded;
+};
+
+bool storeTileJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ char path[PATH_MAX_LENGTH];
+ char temporary[PATH_MAX_LENGTH + 8];
+ char prepared[PATH_MAX_LENGTH];
+ if (!tilePath(path, sizeof(path), job->root, job->region, job->tile)) {
+ return false;
+ }
+ snprintf(temporary, sizeof(temporary), "%s.tmp", path);
+ char folder[PATH_MAX_LENGTH];
+ if (tileFolderPath(folder, sizeof(folder), job->root, job->region, job->tile) && strcmp(folder, job->lastFolder) != 0) {
+ makeFolders(job->root, job->region, job->tile);
+ snprintf(job->lastFolder, PATH_MAX_LENGTH, "%s", folder);
+ }
+ // Only tiles that are not on the card are fetched, a prepared copy can only be left from a deleted original
+ if (preparedPath(prepared, sizeof(prepared), job->root, job->region, job->tile) && fileExists(prepared)) {
+ remove(prepared);
+ }
+ // A write of a few hundred KB gives way to tiles on screen, the tile stays in memory and is written again
+ const bool ok = io.writeFile(path, temporary, *job->body, true);
+ job->yielded = io.yielded();
+ return ok;
+}
+
+constexpr int MAX_DELETE_COLUMNS = 4096;
+
+struct ColumnsJob {
+ char folder[PATH_MAX_LENGTH];
+ int* columns;
+ int count;
+};
+
+bool listColumnsJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ job->count = 0;
+ return listDirectory(job->folder, [](const char* name, bool isDirectory, void* data) {
+ auto* columnsJob = static_cast(data);
+ if (isDirectory && name[0] >= '0' && name[0] <= '9' && columnsJob->count < MAX_DELETE_COLUMNS) {
+ columnsJob->columns[columnsJob->count++] = atoi(name);
+ }
+ return true;
+ }, job);
+}
+
+struct RemoveJob {
+ char path[PATH_MAX_LENGTH];
+ bool folder;
+};
+
+bool removeJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ return (job->folder ? rmdir(job->path) : remove(job->path)) == 0;
+}
+
+} // namespace
+
+bool Downloader::start(const char* root, TileStore* tileStore, IoTask* ioTask) {
+ snprintf(rootPath, sizeof(rootPath), "%s", root);
+ store = tileStore;
+ io = ioTask;
+ mutex = xSemaphoreCreateMutex();
+ wake = xSemaphoreCreateBinary();
+ stopped = xSemaphoreCreateBinary();
+ connectionsDone = xSemaphoreCreateCounting(CONNECTIONS, 0);
+ tileJsonLock = xSemaphoreCreateMutex();
+ browseWake = xSemaphoreCreateBinary();
+ browseStopped = xSemaphoreCreateBinary();
+ handshakeLock = xSemaphoreCreateBinary();
+ if (handshakeLock != nullptr) {
+ xSemaphoreGive(handshakeLock);
+ }
+ if (handshakeLock == nullptr || mutex == nullptr || wake == nullptr || stopped == nullptr || connectionsDone == nullptr || tileJsonLock == nullptr ||
+ browseWake == nullptr || browseStopped == nullptr) {
+ return false;
+ }
+ current = {};
+ current.state = State::Idle;
+ stopRequested = false;
+ if (!startNetworkTask(controlMain, "maps-download", CONTROL_STACK, this, &controlTask)) {
+ controlTask = nullptr;
+ return false;
+ }
+ if (!startNetworkTask(browseMain, "maps-browse", CONTROL_STACK, this, &browseTask)) {
+ browseTask = nullptr;
+ logError("Browse task not started");
+ }
+ return true;
+}
+
+void Downloader::stop() {
+ if (controlTask != nullptr) {
+ stopRequested = true;
+ cancelRequested = true;
+ xSemaphoreGive(wake);
+ xSemaphoreTake(stopped, portMAX_DELAY);
+ endNetworkTask(controlTask);
+ controlTask = nullptr;
+ }
+ if (browseTask != nullptr) {
+ stopRequested = true;
+ xSemaphoreGive(browseWake);
+ xSemaphoreTake(browseStopped, portMAX_DELAY);
+ endNetworkTask(browseTask);
+ browseTask = nullptr;
+ }
+ if (mutex != nullptr) {
+ closeIdleClients(0);
+ }
+ for (SemaphoreHandle_t* handle : { &mutex, &wake, &stopped, &connectionsDone, &tileJsonLock, &browseWake, &browseStopped, &handshakeLock }) {
+ if (*handle != nullptr) {
+ vSemaphoreDelete(*handle);
+ *handle = nullptr;
+ }
+ }
+}
+
+bool Downloader::queue(Job next, const char* region) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ job = next;
+ snprintf(jobRegion, sizeof(jobRegion), "%s", region);
+ xSemaphoreGive(mutex);
+ // A running download is paused, it continues from its meta.json later
+ if (busy) {
+ cancelRequested = true;
+ }
+ busy = true;
+ xSemaphoreGive(wake);
+ return true;
+}
+
+bool Downloader::download(const char* region) {
+ return queue(Job::Download, region);
+}
+
+bool Downloader::remove(const char* region) {
+ return queue(Job::Delete, region);
+}
+
+bool Downloader::update(const char* region) {
+ return queue(Job::Update, region);
+}
+
+void Downloader::setCatalogPath(const char* path) {
+ snprintf(catalogPath, sizeof(catalogPath), "%s", path);
+}
+
+namespace {
+
+struct CatalogJob {
+ const char* path;
+ FileBuffer* buffer;
+};
+
+bool readCatalogJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ // In one go: IoTask::readFile gives way to the tiles on screen and then fails, which a background job would
+ // do all the time while the map loads
+ return job->buffer->load(job->path);
+}
+
+struct OutlineList {
+ TileKey* tiles;
+ int64_t count;
+ int64_t capacity;
+};
+
+} // namespace
+
+bool Downloader::loadOutlineTiles(const RegionMeta& region) {
+ freeOutlineTiles();
+ if (region.outline[0] == '\0') {
+ return true;
+ }
+ FileBuffer file;
+ CatalogJob job { catalogPath, &file };
+ RegionCatalog catalog;
+ if (catalogPath[0] == '\0' || !io->run(IoPriority::Background, readCatalogJob, &job) || !catalog.open(file.bytes(), file.size())) {
+ logError("Region catalog %s not readable", catalogPath);
+ return false;
+ }
+ int index = -1;
+ char name[sizeof(region.outline)];
+ for (int i = 0; i < catalog.count() && index < 0; i++) {
+ catalog.copyName(i, name, sizeof(name));
+ if (strcmp(name, region.outline) == 0) {
+ index = i;
+ }
+ }
+ if (index < 0 || region.total <= 0) {
+ logError("Region %s is not in the catalog", region.outline);
+ return false;
+ }
+ OutlineList list { static_cast(largeAlloc(sizeof(TileKey) * static_cast(region.total))), 0, region.total };
+ if (list.tiles == nullptr) {
+ return false;
+ }
+ for (int z = region.minZoom; z <= region.maxZoom; z++) {
+ catalog.forEachTile(index, z, [](const TileKey& tile, void* context) {
+ auto* tiles = static_cast(context);
+ if (tiles->count >= tiles->capacity) {
+ return false;
+ }
+ tiles->tiles[tiles->count++] = tile;
+ return true;
+ }, &list);
+ }
+ outlineTiles = list.tiles;
+ outlineCount = list.count;
+ if (outlineCount != region.total) {
+ logError("Region %s: %d tiles in the outline, %d expected", region.name, static_cast(outlineCount), static_cast(region.total));
+ }
+ return true;
+}
+
+namespace {
+
+struct PlaceJob {
+ char path[PATH_MAX_LENGTH];
+ double fx;
+ double fy;
+ PlaceName place;
+ bool found;
+};
+
+bool placeJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ FileInput input;
+ PlaceFinder finder;
+ job->found = input.open(job->path) && finder.init() && finder.nearest(&input, job->fx, job->fy, job->place);
+ return true;
+}
+
+} // namespace
+
+bool Downloader::findPlaceName(const RegionMeta& region, char* out, size_t size) {
+ const double lat = (region.box.south + region.box.north) / 2.0;
+ const double lon = (region.box.west + region.box.east) / 2.0;
+ // z12 shows villages, z10 only towns and cities; a village right at the centre wins over a town far away
+ for (const int z : { 12, 10 }) {
+ if (z < region.minZoom || z > region.maxZoom) {
+ continue;
+ }
+ const TileKey tile = tileAt(lat, lon, z);
+ PlaceJob job {};
+ if (!tilePath(job.path, sizeof(job.path), rootPath, region.name, tile)) {
+ continue;
+ }
+ job.fx = lonToWorldX(lon, z) / TILE_SIZE - tile.x;
+ job.fy = latToWorldY(lat, z) / TILE_SIZE - tile.y;
+ io->run(IoPriority::Background, placeJob, &job);
+ if (job.found && job.place.name[0] != '\0') {
+ snprintf(out, size, "%s", job.place.name);
+ return true;
+ }
+ }
+ return false;
+}
+
+void Downloader::freeOutlineTiles() {
+ largeFree(outlineTiles);
+ outlineTiles = nullptr;
+ outlineCount = 0;
+}
+
+bool Downloader::tileForIndex(const RegionMeta& region, int64_t index, TileKey& tile) const {
+ if (outlineTiles != nullptr) {
+ if (index < 0 || index >= outlineCount) {
+ return false;
+ }
+ tile = outlineTiles[index];
+ return true;
+ }
+ return tileAtIndex(region.plan(), index, tile);
+}
+
+void Downloader::setView(const BoundingBox& box) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ viewBox = box;
+ viewKnown = true;
+ xSemaphoreGive(mutex);
+}
+
+Downloader::Status Downloader::status() {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ Status copy = current;
+ xSemaphoreGive(mutex);
+ return copy;
+}
+
+void Downloader::setStatus(State state, const char* message) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ current.state = state;
+ snprintf(current.message, sizeof(current.message), "%s", message);
+ xSemaphoreGive(mutex);
+ if (state == State::Failed) {
+ logError("Download: %s", message);
+ } else {
+ logInfo("Download: %s", message);
+ }
+}
+
+void Downloader::controlMain(void* context) {
+ auto* downloader = static_cast(context);
+ downloader->controlLoop();
+ xSemaphoreGive(downloader->stopped);
+ vTaskSuspend(nullptr);
+}
+
+void Downloader::controlLoop() {
+ while (!stopRequested) {
+ if (xSemaphoreTake(wake, SWEEP_TICKS) != pdTRUE) {
+ closeIdleClients(IDLE_CLOSE_MS);
+ // Outdated regions are looked for at most once a minute while the map is left alone
+ if (updatesAllowed && !stopRequested && nowMs() - lastUpdateCheckMs >= UPDATE_CHECK_MS && wifiConnected()) {
+ lastUpdateCheckMs = nowMs();
+ char region[REGION_NAME_MAX + 1];
+ if (urlTemplate[0] != '\0' && findOutdated(region, sizeof(region))) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool jobless = job == Job::None;
+ if (jobless) {
+ snprintf(current.region, sizeof(current.region), "%s", region);
+ busy = true;
+ }
+ xSemaphoreGive(mutex);
+ if (jobless) {
+ cancelRequested = false;
+ runUpdate(false);
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ busy = job != Job::None;
+ xSemaphoreGive(mutex);
+ if (busy) {
+ xSemaphoreGive(wake);
+ }
+ }
+ }
+ }
+ continue;
+ }
+ if (stopRequested) {
+ break;
+ }
+ cancelRequested = false;
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const Job requested = job;
+ job = Job::None;
+ snprintf(current.region, sizeof(current.region), "%s", jobRegion);
+ xSemaphoreGive(mutex);
+ if (requested == Job::Download) {
+ runDownload();
+ } else if (requested == Job::Delete) {
+ runDelete();
+ } else if (requested == Job::Update) {
+ runUpdate(true);
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool more = job != Job::None;
+ busy = more;
+ xSemaphoreGive(mutex);
+ }
+}
+
+bool Downloader::fetchTileJson(Connection& connection) {
+ if (timing) {
+ // Plain HTTP first: name lookup and TCP without TLS. The HTTPS request after it then finds the name cached.
+ esp_http_client_config_t config = {};
+ config.url = "http://tiles.openfreemap.org/planet";
+ config.user_agent = USER_AGENT;
+ config.timeout_ms = HTTP_TIMEOUT_MS;
+ config.event_handler = onHttpEvent;
+ esp_http_client_handle_t plain = esp_http_client_init(&config);
+ if (plain != nullptr) {
+ const int64_t startUs = esp_timer_get_time();
+ connectedAtMs = 0;
+ const esp_err_t opened = esp_http_client_open(plain, 0);
+ const int64_t openedUs = esp_timer_get_time();
+ const int64_t headers = opened == ESP_OK ? esp_http_client_fetch_headers(plain) : -1;
+ const int64_t headersUs = esp_timer_get_time();
+ logInfo("Timing http: start t=%u ms, connected +%u ms (DNS and TCP), open +%u, headers +%u (%d, HTTP %d)",
+ static_cast(startUs / 1000), connectedAtMs > 0 ? connectedAtMs - static_cast(startUs / 1000) : 0, msSince(startUs, openedUs),
+ msSince(startUs, headersUs), static_cast(headers), esp_http_client_get_status_code(plain));
+ esp_http_client_close(plain);
+ esp_http_client_cleanup(plain);
+ }
+ }
+ // Through a pooled keep-alive connection, which the tile requests after it then reuse
+ takeClient(connection);
+ auto* client = static_cast(connection.client);
+ if (client == nullptr || connection.body == nullptr) {
+ return false;
+ }
+ // The TileJSON is read as plain text
+ esp_http_client_delete_header(client, "Accept-Encoding");
+ esp_http_client_set_url(client, TILEJSON_URL);
+ connection.body->clear();
+ const int64_t startUs = esp_timer_get_time();
+ const esp_err_t result = perform(connection);
+ const int64_t doneUs = esp_timer_get_time();
+ esp_http_client_set_header(client, "Accept-Encoding", "gzip");
+ const int status = esp_http_client_get_status_code(client);
+ ChunkedBody& text = *connection.body;
+ bool ok = false;
+ if (result != ESP_OK || status != 200 || text.failed()) {
+ logError("TileJSON request failed: %s, HTTP %d", esp_err_to_name(result), status);
+ dropClient(connection);
+ } else {
+ if (timing) {
+ logInfo("Timing https: start t=%u ms, connect %u ms (DNS, TCP, TLS), whole request %u ms (%u bytes)",
+ static_cast(startUs / 1000), static_cast(connection.connectMs), msSince(startUs, doneUs),
+ static_cast(text.size()));
+ }
+ text.put('\0');
+ ok = !text.failed() && text.contiguous() != nullptr &&
+ parseTileJson(reinterpret_cast(text.contiguous()), urlTemplate, sizeof(urlTemplate), version, sizeof(version));
+ if (!ok) {
+ logError("TileJSON has no usable tile URL (%u bytes read)", static_cast(text.size()));
+ }
+ }
+ text.clear();
+ if (ok) {
+ logInfo("Tile source %s (version %s)", urlTemplate, version);
+ }
+ return ok;
+}
+
+bool Downloader::readMeta(const char* region, RegionMeta& out) {
+ MetaJob job {};
+ snprintf(job.path, sizeof(job.path), "%s/maps/%s/meta.json", rootPath, region);
+ job.meta = &out;
+ return io->run(IoPriority::Background, readMetaJob, &job);
+}
+
+bool Downloader::writeMeta(const RegionMeta& out) {
+ RegionMeta copy = out;
+ MetaJob job {};
+ snprintf(job.path, sizeof(job.path), "%s/maps/%s/meta.json", rootPath, copy.name);
+ job.meta = ©
+ return io->run(IoPriority::Background, writeMetaJob, &job);
+}
+
+bool Downloader::tileOnCard(const char* region, const TileKey& tile, bool& inThisRegion) {
+ OnCardJob job { rootPath, store, region, tile, false };
+ const bool found = io->run(IoPriority::Background, onCardJob, &job);
+ inThisRegion = job.inThisRegion;
+ return found;
+}
+
+bool Downloader::downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status) {
+ char url[200];
+ if (!formatTileUrl(urlTemplate, tile, url, sizeof(url))) {
+ return false;
+ }
+ connection.body->clear();
+ auto* client = static_cast(connection.client);
+ esp_http_client_set_url(client, url);
+ const esp_err_t result = perform(connection);
+ status = esp_http_client_get_status_code(client);
+ const size_t size = connection.body->size();
+ if (result != ESP_OK || connection.body->failed() || size > MAX_TILE_BYTES || (status != 200 && status != 204 && status != 404)) {
+ if (connection.body->failed()) {
+ logError("Tile %d/%d/%d: no memory for %u bytes", tile.z, tile.x, tile.y, static_cast(size));
+ }
+ connection.body->trim();
+ return false;
+ }
+ if (status != 200 || size == 0) {
+ // An empty tile, the sea or empty land at this zoom level
+ return true;
+ }
+ // Card work waits until the map has been left alone for a moment, writes hold the display's shared bus
+ while (!idle && !stopRequested && !cancelRequested) {
+ vTaskDelay(pdMS_TO_TICKS(50));
+ }
+ StoreTileJob job { rootPath, region, tile, connection.body, connection.lastFolder, false };
+ bool stored = io->run(IoPriority::Background, storeTileJob, &job);
+ for (int attempt = 0; !stored && job.yielded && attempt < 20 && !stopRequested; attempt++) {
+ vTaskDelay(pdMS_TO_TICKS(50));
+ stored = io->run(IoPriority::Background, storeTileJob, &job);
+ }
+ connection.body->trim();
+ if (!stored) {
+ return false;
+ }
+ connection.received += static_cast(size);
+ return true;
+}
+
+bool Downloader::claimIndex(int64_t& index, int connectionIndex) {
+ while (true) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool available = nextIndex < meta.total && !failed && !cancelRequested && !stopRequested;
+ if (!available) {
+ xSemaphoreGive(mutex);
+ return false;
+ }
+ // Tiles up to ALONE_MAX_ZOOM can be over 1 MB each: one is downloaded at a time, with no other tile next to it
+ TileKey tile {};
+ bool large = tileAtIndex(meta.plan(), nextIndex, tile) && tile.z <= ALONE_MAX_ZOOM;
+ bool othersActive = false;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ if (i == connectionIndex || activeIndex[i] < 0) {
+ continue;
+ }
+ othersActive = true;
+ large = large || (tileAtIndex(meta.plan(), activeIndex[i], tile) && tile.z <= ALONE_MAX_ZOOM);
+ }
+ if (!large || !othersActive) {
+ index = nextIndex++;
+ activeIndex[connectionIndex] = index;
+ xSemaphoreGive(mutex);
+ return true;
+ }
+ xSemaphoreGive(mutex);
+ vTaskDelay(pdMS_TO_TICKS(100));
+ }
+}
+
+void Downloader::finishIndex(int connectionIndex) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ activeIndex[connectionIndex] = -1;
+ // Tiles finish out of order, the saved count is the first tile that is not finished yet
+ int64_t done = nextIndex;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ if (activeIndex[i] >= 0 && activeIndex[i] < done) {
+ done = activeIndex[i];
+ }
+ }
+ meta.done = done;
+ uint64_t bytes = 0;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ bytes += connections[i].received;
+ }
+ current.done = done;
+ current.bytes = meta.bytes + bytes;
+ sinceSave++;
+ const bool save = sinceSave >= SAVE_EVERY_TILES;
+ if (save) {
+ sinceSave = 0;
+ }
+ xSemaphoreGive(mutex);
+ if (save) {
+ saveProgress();
+ allTilesChanged = true;
+ tilesChanged = true;
+ }
+}
+
+void Downloader::saveProgress() {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ RegionMeta copy = meta;
+ uint64_t bytes = 0;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ bytes += connections[i].received;
+ }
+ copy.bytes = meta.bytes + bytes;
+ xSemaphoreGive(mutex);
+ writeMeta(copy);
+}
+
+void Downloader::connectionMain(void* context) {
+ auto* connection = static_cast(context);
+ connection->owner->connectionLoop(*connection);
+ xSemaphoreGive(connection->owner->connectionsDone);
+ vTaskSuspend(nullptr);
+}
+
+void Downloader::connectionLoop(Connection& connection) {
+ int64_t index;
+ while (claimIndex(index, connection.index)) {
+ TileKey tile {};
+ if (!tileForIndex(meta, index, tile)) {
+ finishIndex(connection.index);
+ continue;
+ }
+ bool inThisRegion = false;
+ if (tileOnCard(meta.name, tile, inThisRegion)) {
+ finishIndex(connection.index);
+ continue;
+ }
+ bool ok = false;
+ int status = 0;
+ const uint32_t tileStart = nowMs();
+ const uint64_t receivedBefore = connection.received;
+ for (int attempt = 1; attempt <= MAX_ATTEMPTS && !ok && !cancelRequested; attempt++) {
+ takeClient(connection);
+ ok = connection.client != nullptr && downloadTile(connection, meta.name, tile, status);
+ if (!ok) {
+ logError("[%d] Tile %d/%d/%d failed (HTTP %d), attempt %d", connection.index, tile.z, tile.x, tile.y, status, attempt);
+ dropClient(connection);
+ vTaskDelay(pdMS_TO_TICKS(1000 * attempt));
+ }
+ }
+ if (!ok) {
+ failed = true;
+ }
+ // The limit holds for the real size too, the estimate can be lower for dense city centres
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ uint64_t bytes = meta.bytes;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ bytes += connections[i].received;
+ }
+ xSemaphoreGive(mutex);
+ if (bytes > MAX_DOWNLOAD_BYTES) {
+ failed = true;
+ }
+ if (ok) {
+ logInfo("[%d] Tile %d/%d/%d: %u bytes in %u ms (HTTP %d)", connection.index, tile.z, tile.x, tile.y,
+ static_cast(connection.received - receivedBefore), nowMs() - tileStart, status);
+ finishIndex(connection.index);
+ }
+ }
+ // Kept open for the next download, browsing or an update
+ giveClient(connection);
+}
+
+void Downloader::runDownload() {
+ if (!readMeta(current.region, meta)) {
+ setStatus(State::Failed, "Region information is missing");
+ return;
+ }
+ snprintf(meta.name, sizeof(meta.name), "%s", current.region);
+ if (meta.total > MAX_DOWNLOAD_TILES) {
+ setStatus(State::Failed, "Area is larger than 3000 tiles");
+ return;
+ }
+ if (!loadOutlineTiles(meta)) {
+ setStatus(State::Failed, "Region outline is missing");
+ return;
+ }
+ if (!wifiConnected()) {
+ setStatus(State::Failed, "No Wi-Fi connection");
+ return;
+ }
+ setStatus(State::Connecting, "Connecting to OpenFreeMap");
+ logMemory("download start");
+ connections[0].owner = this;
+ connections[0].index = 0;
+ connections[0].body = &bodies[0];
+ const bool tileJson = ensureTileJson(connections[0]);
+ giveClient(connections[0]);
+ bodies[0].release();
+ if (!tileJson) {
+ setStatus(State::Failed, "OpenFreeMap is not reachable");
+ return;
+ }
+ if (meta.source[0] == '\0') {
+ snprintf(meta.source, sizeof(meta.source), "%s", urlTemplate);
+ }
+ snprintf(meta.version, sizeof(meta.version), "%s", version);
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ current.total = meta.total;
+ current.done = meta.done;
+ current.bytes = meta.bytes;
+ nextIndex = meta.done;
+ sinceSave = 0;
+ failed = false;
+ xSemaphoreGive(mutex);
+ setStatus(State::Downloading, "Downloading");
+
+ const uint32_t start = nowMs();
+ int started = 0;
+ for (int i = 0; i < CONNECTIONS; i++) {
+ Connection& connection = connections[i];
+ connection = { this, i, nullptr, &bodies[i], 0, nullptr, "" };
+ activeIndex[i] = -1;
+ char name[16];
+ snprintf(name, sizeof(name), "maps-http%d", i);
+ if (startNetworkTask(connectionMain, name, CONNECTION_STACK, &connection, &connection.task)) {
+ started++;
+ } else {
+ connection.task = nullptr;
+ }
+ }
+ for (int i = 0; i < started; i++) {
+ xSemaphoreTake(connectionsDone, portMAX_DELAY);
+ }
+ for (int i = 0; i < CONNECTIONS; i++) {
+ if (connections[i].task != nullptr) {
+ endNetworkTask(connections[i].task);
+ connections[i].task = nullptr;
+ }
+ bodies[i].release();
+ }
+ freeOutlineTiles();
+ logMemory("download end");
+
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ for (int i = 0; i < CONNECTIONS; i++) {
+ meta.bytes += connections[i].received;
+ connections[i].received = 0;
+ }
+ if (!failed && !cancelRequested) {
+ meta.done = meta.total;
+ }
+ time_t now = time(nullptr);
+ struct tm local {};
+ localtime_r(&now, &local);
+ strftime(meta.date, sizeof(meta.date), "%Y-%m-%d", &local);
+ current.done = meta.done;
+ current.bytes = meta.bytes;
+ xSemaphoreGive(mutex);
+ // An area framed on the map is named after the nearest town in its own tiles, now that they are on the card
+ char place[sizeof(meta.title)];
+ if (meta.isComplete() && meta.outline[0] == '\0' && findPlaceName(meta, place, sizeof(place))) {
+ logInfo("Region %s is named %s", meta.name, place);
+ setRegionTitle(meta, place);
+ }
+ writeMeta(meta);
+ store->setRegionVersion(meta.name, meta.version);
+ allTilesChanged = true;
+ tilesChanged = true;
+
+ const uint32_t seconds = (nowMs() - start) / 1000;
+ char message[64];
+ if (meta.bytes > MAX_DOWNLOAD_BYTES) {
+ setStatus(State::Failed, "Stopped at the 50 MB limit");
+ } else if (failed) {
+ setStatus(State::Failed, "Download failed, it continues later");
+ } else if (cancelRequested) {
+ setStatus(State::Done, "Download paused");
+ } else {
+ snprintf(message, sizeof(message), "Downloaded %d KB in %u s", static_cast(meta.bytes / 1024), static_cast(seconds));
+ setStatus(State::Done, message);
+ }
+}
+
+struct DeleteColumnJob {
+ const char* root;
+ const char* region;
+ int z;
+ int x;
+ const RegionMeta* others;
+ int otherCount;
+};
+
+namespace {
+
+bool deleteColumnJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ char column[PATH_MAX_LENGTH];
+ snprintf(column, sizeof(column), "%s/maps/%s/%d/%d", job->root, job->region, job->z, job->x);
+ struct Files {
+ DeleteColumnJob* job;
+ const char* column;
+ } files { job, column };
+ listDirectory(column, [](const char* fileName, bool fileIsDirectory, void* fileData) {
+ auto* f = static_cast(fileData);
+ if (fileIsDirectory) {
+ return true;
+ }
+ char file[PATH_MAX_LENGTH];
+ snprintf(file, sizeof(file), "%s/%s", f->column, fileName);
+ // Only originals move to a region that still needs them, prepared copies are made again there
+ const size_t nameLength = strlen(fileName);
+ if (nameLength < 5 || (strcmp(fileName + nameLength - 4, ".pbf") != 0 && strcmp(fileName + nameLength - 4, ".PBF") != 0)) {
+ remove(file);
+ return true;
+ }
+ const TileKey tile { f->job->z, f->job->x, atoi(fileName) };
+ for (int i = 0; i < f->job->otherCount; i++) {
+ const RegionMeta& other = f->job->others[i];
+ const TileRange range = tileRange(other.box, tile.z);
+ char otherPath[PATH_MAX_LENGTH];
+ if (tile.z >= other.minZoom && tile.z <= other.maxZoom && tile.x >= range.minX && tile.x <= range.maxX &&
+ tile.y >= range.minY && tile.y <= range.maxY && tilePath(otherPath, sizeof(otherPath), f->job->root, other.name, tile) &&
+ !fileExists(otherPath)) {
+ makeFolders(f->job->root, other.name, tile);
+ if (rename(file, otherPath) == 0) {
+ return true;
+ }
+ }
+ }
+ remove(file);
+ return true;
+ }, &files);
+ rmdir(column);
+ return true;
+}
+
+} // namespace
+
+bool Downloader::ensureTileJson(Connection& connection) {
+ // The region download and the browse task may both need it first
+ xSemaphoreTake(tileJsonLock, portMAX_DELAY);
+ const bool ok = urlTemplate[0] != '\0' || fetchTileJson(connection);
+ xSemaphoreGive(tileJsonLock);
+ return ok;
+}
+
+bool Downloader::isOnline() {
+ return wifiConnected();
+}
+
+void Downloader::keepBrowse(int sourceZoom, int minX, int minY, int maxX, int maxY) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ int kept = 0;
+ for (int i = 0; i < browseCount; i++) {
+ const TileKey& tile = browseQueue[i];
+ const bool column = minX <= maxX ? tile.x >= minX && tile.x <= maxX : tile.x >= minX || tile.x <= maxX;
+ if (tile.z == sourceZoom && column && tile.y >= minY && tile.y <= maxY) {
+ browseQueue[kept++] = tile;
+ }
+ }
+ // Dropped tiles no longer count towards "Fetching detail done/total"
+ browseTotal -= browseCount - kept;
+ browseCount = kept;
+ xSemaphoreGive(mutex);
+}
+
+void Downloader::browse(const TileKey* tiles, int count) {
+ if (browseTask == nullptr) {
+ return;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ bool added = false;
+ if (browseCount == 0 && !browsing) {
+ // A new batch: its progress counts from zero
+ browseDone = 0;
+ browseTotal = 0;
+ }
+ for (int i = 0; i < count; i++) {
+ bool known = false;
+ for (int j = 0; j < browseCount && !known; j++) {
+ known = browseQueue[j] == tiles[i];
+ }
+ for (int j = 0; j < browseEmptyCount && !known; j++) {
+ known = browseEmpty[j] == tiles[i];
+ }
+ if (known || !isValidTile(tiles[i])) {
+ continue;
+ }
+ if (browseCount == BROWSE_QUEUE) {
+ // The oldest request goes, the newest view matters most
+ for (int j = 1; j < BROWSE_QUEUE; j++) {
+ browseQueue[j - 1] = browseQueue[j];
+ }
+ browseCount--;
+ browseTotal--;
+ }
+ browseQueue[browseCount++] = tiles[i];
+ browseTotal++;
+ added = true;
+ }
+ xSemaphoreGive(mutex);
+ if (added) {
+ xSemaphoreGive(browseWake);
+ }
+}
+
+void Downloader::prepareBrowsing() {
+ if (browseTask != nullptr) {
+ warmUp = true;
+ xSemaphoreGive(browseWake);
+ }
+}
+
+void Downloader::browseMain(void* context) {
+ auto* downloader = static_cast(context);
+ downloader->browseLoop();
+ xSemaphoreGive(downloader->browseStopped);
+ vTaskSuspend(nullptr);
+}
+
+bool Downloader::ensureBrowseRegion(const TileKey& tile) {
+ // The region's box grows with the tiles
+ const double west = worldXToLon(static_cast(tile.x) * TILE_SIZE, tile.z);
+ const double east = worldXToLon(static_cast(tile.x + 1) * TILE_SIZE, tile.z);
+ const double north = worldYToLat(static_cast(tile.y) * TILE_SIZE, tile.z);
+ const double south = worldYToLat(static_cast(tile.y + 1) * TILE_SIZE, tile.z);
+ if (!browseMetaReady) {
+ if (!readMeta(BROWSE_REGION, browseMeta)) {
+ browseMeta = {};
+ browseMeta.box = { west, south, east, north };
+ auto makeFolder = [&] {
+ char folder[PATH_MAX_LENGTH];
+ snprintf(folder, sizeof(folder), "%s/maps/%s", rootPath, BROWSE_REGION);
+ mkdir(folder, 0777);
+ return true;
+ };
+ runOnCard(*io, makeFolder, IoPriority::Background);
+ }
+ snprintf(browseMeta.name, sizeof(browseMeta.name), "%s", BROWSE_REGION);
+ browseMeta.minZoom = 0;
+ browseMeta.maxZoom = MAX_SOURCE_ZOOM;
+ browseMeta.total = 1;
+ browseMeta.done = 1;
+ snprintf(browseMeta.version, sizeof(browseMeta.version), "%s", version);
+ time_t now = time(nullptr);
+ struct tm local {};
+ localtime_r(&now, &local);
+ strftime(browseMeta.date, sizeof(browseMeta.date), "%Y-%m-%d", &local);
+ if (!writeMeta(browseMeta)) {
+ return false;
+ }
+ store->addRegion(BROWSE_REGION);
+ store->setRegionVersion(BROWSE_REGION, version);
+ browseMetaReady = true;
+ }
+ browseMeta.box.west = std::min(browseMeta.box.west, west);
+ browseMeta.box.east = std::max(browseMeta.box.east, east);
+ browseMeta.box.south = std::min(browseMeta.box.south, south);
+ browseMeta.box.north = std::max(browseMeta.box.north, north);
+ store->setRegionBounds(BROWSE_REGION, browseMeta.box, browseMeta.minZoom, browseMeta.maxZoom);
+ return true;
+}
+
+bool Downloader::takeTilesChanged(TileKey* tiles, int& count) {
+ if (!tilesChanged.exchange(false)) {
+ return false;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ count = allTilesChanged.exchange(false) ? -1 : arrivedCount;
+ for (int i = 0; i < count; i++) {
+ tiles[i] = arrived[i];
+ }
+ arrivedCount = 0;
+ xSemaphoreGive(mutex);
+ return true;
+}
+
+bool Downloader::fetchBrowseTile(const TileKey& tile) {
+ bool inThisRegion = false;
+ if (tileOnCard(BROWSE_REGION, tile, inThisRegion)) {
+ return true;
+ }
+ if (!ensureBrowseRegion(tile)) {
+ return false;
+ }
+ Connection& connection = browseConnection;
+ const uint64_t before = connection.received;
+ const uint32_t startMs = nowMs();
+ int status = 0;
+ bool ok = false;
+ for (int attempt = 1; attempt <= 2 && !ok && !stopRequested; attempt++) {
+ takeClient(connection);
+ ok = connection.client != nullptr && downloadTile(connection, BROWSE_REGION, tile, status);
+ if (!ok) {
+ dropClient(connection);
+ }
+ }
+ if (!ok) {
+ logError("Browse: tile %d/%d/%d failed (HTTP %d)", tile.z, tile.x, tile.y, status);
+ return false;
+ }
+ if (connection.received == before) {
+ // No data there: remembered, so it is not asked for again
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ browseEmpty[browseEmptyNext] = tile;
+ browseEmptyNext = (browseEmptyNext + 1) % BROWSE_EMPTY;
+ if (browseEmptyCount < BROWSE_EMPTY) {
+ browseEmptyCount++;
+ }
+ xSemaphoreGive(mutex);
+ return true;
+ }
+ browseMeta.bytes += connection.received - before;
+ logInfo("Browse: tile %d/%d/%d, %u bytes in %u ms", tile.z, tile.x, tile.y, static_cast(connection.received - before), nowMs() - startMs);
+ if (++browseUnsaved >= 5) {
+ browseUnsaved = 0;
+ writeMeta(browseMeta);
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ if (arrivedCount < MAX_ARRIVED) {
+ arrived[arrivedCount++] = tile;
+ } else {
+ allTilesChanged = true;
+ }
+ xSemaphoreGive(mutex);
+ tilesChanged = true;
+ return true;
+}
+
+void Downloader::browseLoop() {
+ browseConnection = { this, CONNECTIONS, nullptr, &browseBody, 0, nullptr, "" };
+ while (!stopRequested) {
+ if (xSemaphoreTake(browseWake, SWEEP_TICKS) != pdTRUE) {
+ closeIdleClients(IDLE_CLOSE_MS);
+ continue;
+ }
+ if (warmUp.exchange(false) && wifiConnected()) {
+ ensureTileJson(browseConnection);
+ }
+ while (!stopRequested) {
+ TileKey tile {};
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool any = browseCount > 0;
+ if (any) {
+ tile = browseQueue[0];
+ for (int i = 1; i < browseCount; i++) {
+ browseQueue[i - 1] = browseQueue[i];
+ }
+ browseCount--;
+ }
+ xSemaphoreGive(mutex);
+ if (!any) {
+ break;
+ }
+ if (!wifiConnected() || !ensureTileJson(browseConnection)) {
+ // Offline: the map says so, the requests are dropped and come again with the next view
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ browseCount = 0;
+ browseDone = 0;
+ browseTotal = 0;
+ xSemaphoreGive(mutex);
+ break;
+ }
+ browsing = true;
+ const bool fetched = fetchBrowseTile(tile);
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ browseDone++;
+ xSemaphoreGive(mutex);
+ if (!fetched) {
+ // A failing server is not asked again at once
+ vTaskDelay(pdMS_TO_TICKS(2000));
+ }
+ }
+ browsing = false;
+ giveClient(browseConnection);
+ browseBody.release();
+ if (browseUnsaved > 0) {
+ browseUnsaved = 0;
+ writeMeta(browseMeta);
+ }
+ }
+ dropClient(browseConnection);
+}
+
+void Downloader::browseProgress(int& done, int& total) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ done = browseDone;
+ total = std::max(browseTotal, browseDone);
+ xSemaphoreGive(mutex);
+}
+
+void Downloader::noteArrived(const TileKey& tile) {
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ if (arrivedCount < MAX_ARRIVED) {
+ arrived[arrivedCount++] = tile;
+ } else {
+ allTilesChanged = true;
+ }
+ xSemaphoreGive(mutex);
+ tilesChanged = true;
+}
+
+bool Downloader::findOutdated(char* region, size_t size) {
+ for (int i = 0; i < store->regionCount(); i++) {
+ const char* name = store->regionName(i);
+ // Browsed tiles have no plan to walk through, they stay as they were fetched
+ if (strcmp(name, BROWSE_REGION) == 0) {
+ continue;
+ }
+ RegionMeta candidate {};
+ if (readMeta(name, candidate) && candidate.isComplete() && candidate.version[0] != '\0' && strcmp(candidate.version, version) != 0) {
+ snprintf(region, size, "%s", name);
+ return true;
+ }
+ }
+ return false;
+}
+
+namespace {
+
+/** Which tiles of a region are already at the new version: maps//update.bin, so an update resumes. */
+struct UpdateProgress {
+ static constexpr uint32_t MAGIC = 0x5055'4D54; // "TMUP"
+ static constexpr size_t HEADER = 4 + 4 + 48;
+ char version[48];
+ int64_t total;
+ int64_t done;
+ uint8_t* bits;
+
+ bool isSet(int64_t index) const { return (bits[index >> 3] & (1u << (index & 7))) != 0; }
+ void set(int64_t index) {
+ if (!isSet(index)) {
+ bits[index >> 3] = static_cast(bits[index >> 3] | (1u << (index & 7)));
+ done++;
+ }
+ }
+ size_t bitBytes() const { return static_cast((total + 7) / 8); }
+};
+
+struct ProgressJob {
+ char path[PATH_MAX_LENGTH];
+ UpdateProgress* progress;
+ bool write;
+};
+
+bool progressJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ UpdateProgress& progress = *job->progress;
+ if (job->write) {
+ const size_t size = UpdateProgress::HEADER + progress.bitBytes();
+ auto* bytes = static_cast(largeAlloc(size));
+ if (bytes == nullptr) {
+ return false;
+ }
+ const uint32_t magic = UpdateProgress::MAGIC;
+ const auto total = static_cast(progress.total);
+ memcpy(bytes, &magic, 4);
+ memcpy(bytes + 4, &total, 4);
+ memcpy(bytes + 8, progress.version, 48);
+ memcpy(bytes + UpdateProgress::HEADER, progress.bits, progress.bitBytes());
+ char temporary[PATH_MAX_LENGTH + 8];
+ snprintf(temporary, sizeof(temporary), "%s.tmp", job->path);
+ const bool ok = io.writeFile(job->path, temporary, bytes, size, false);
+ largeFree(bytes);
+ return ok;
+ }
+ FILE* file = fopen(job->path, "rb");
+ if (file == nullptr) {
+ return false;
+ }
+ uint8_t header[UpdateProgress::HEADER];
+ bool ok = fread(header, 1, sizeof(header), file) == sizeof(header);
+ uint32_t magic = 0;
+ uint32_t total = 0;
+ if (ok) {
+ memcpy(&magic, header, 4);
+ memcpy(&total, header + 4, 4);
+ ok = magic == UpdateProgress::MAGIC && total == progress.total && memcmp(header + 8, progress.version, 48) == 0 &&
+ fread(progress.bits, 1, progress.bitBytes(), file) == progress.bitBytes();
+ }
+ fclose(file);
+ if (!ok) {
+ memset(progress.bits, 0, progress.bitBytes());
+ }
+ return ok;
+}
+
+} // namespace
+
+void Downloader::runUpdate(bool manual) {
+ RegionMeta target {};
+ if (!readMeta(current.region, target)) {
+ setStatus(State::Failed, "Region information is missing");
+ return;
+ }
+ snprintf(target.name, sizeof(target.name), "%s", current.region);
+ if (!target.isComplete()) {
+ // An unfinished download continues as a download, which fetches from the newest version anyway
+ setStatus(State::Failed, "Finish the download first");
+ return;
+ }
+ if (!wifiConnected()) {
+ setStatus(State::Failed, "No Wi-Fi connection");
+ return;
+ }
+ Connection& connection = connections[0];
+ connection.owner = this;
+ connection.index = 0;
+ connection.body = &bodies[0];
+ connection.received = 0;
+ connection.lastFolder[0] = '\0';
+ if (!ensureTileJson(connection)) {
+ giveClient(connection);
+ setStatus(State::Failed, "OpenFreeMap is not reachable");
+ return;
+ }
+ if (strcmp(target.version, version) == 0) {
+ giveClient(connection);
+ setStatus(State::Done, "Region is up to date");
+ return;
+ }
+
+ UpdateProgress progress {};
+ snprintf(progress.version, sizeof(progress.version), "%s", version);
+ progress.total = target.total;
+ progress.bits = static_cast(largeAlloc(progress.bitBytes()));
+ if (progress.bits == nullptr) {
+ giveClient(connection);
+ setStatus(State::Failed, "Out of memory");
+ return;
+ }
+ memset(progress.bits, 0, progress.bitBytes());
+ ProgressJob progressFile {};
+ snprintf(progressFile.path, sizeof(progressFile.path), "%s/maps/%s/update.bin", rootPath, target.name);
+ progressFile.progress = &progress;
+ io->run(IoPriority::Background, progressJob, &progressFile);
+ for (int64_t i = 0; i < progress.total; i++) {
+ progress.done += progress.isSet(i) ? 1 : 0;
+ }
+ logInfo("Update %s from %s to %s: %d of %d tiles done", target.name, target.version, version, static_cast(progress.done),
+ static_cast(progress.total));
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ current.total = progress.total;
+ current.done = progress.done;
+ current.bytes = 0;
+ const BoundingBox view = viewBox;
+ const bool hasView = viewKnown;
+ xSemaphoreGive(mutex);
+ setStatus(State::Updating, manual ? "Updating" : "Updating in idle time");
+
+ const DownloadPlan plan = target.plan();
+ if (!loadOutlineTiles(target)) {
+ largeFree(progress.bits);
+ giveClient(connection);
+ setStatus(State::Failed, "Region outline is missing");
+ return;
+ }
+ bool failedTile = false;
+ int unsaved = 0;
+ auto stopNow = [&] { return stopRequested || cancelRequested || (!manual && !updatesAllowed); };
+ auto updateOne = [&](int64_t index) {
+ TileKey tile {};
+ if (index < 0 || index >= progress.total || progress.isSet(index) || !tileForIndex(target, index, tile)) {
+ return true;
+ }
+ bool ok = false;
+ int status = 0;
+ for (int attempt = 1; attempt <= 2 && !ok && !stopRequested; attempt++) {
+ takeClient(connection);
+ ok = connection.client != nullptr && downloadTile(connection, target.name, tile, status);
+ if (!ok) {
+ dropClient(connection);
+ }
+ }
+ if (!ok) {
+ logError("Update: tile %d/%d/%d failed (HTTP %d)", tile.z, tile.x, tile.y, status);
+ return false;
+ }
+ if (status != 200) {
+ // The new version has nothing here any more: the old tile goes
+ RemoveJob old {};
+ if (tilePath(old.path, sizeof(old.path), rootPath, target.name, tile)) {
+ io->run(IoPriority::Background, removeJob, &old);
+ }
+ if (preparedPath(old.path, sizeof(old.path), rootPath, target.name, tile)) {
+ io->run(IoPriority::Background, removeJob, &old);
+ }
+ }
+ progress.set(index);
+ noteArrived(tile);
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ current.done = progress.done;
+ xSemaphoreGive(mutex);
+ if (++unsaved >= SAVE_EVERY_TILES) {
+ unsaved = 0;
+ progressFile.write = true;
+ io->run(IoPriority::Background, progressJob, &progressFile);
+ }
+ return true;
+ };
+ // The tiles in view first, from the lowest zoom level, then all the others in download order
+ if (hasView && outlineTiles != nullptr) {
+ for (int64_t index = 0; index < outlineCount && !stopNow() && !failedTile; index++) {
+ const TileRange seen = tileRange(view, outlineTiles[index].z);
+ const TileKey& tile = outlineTiles[index];
+ if (tile.x >= seen.minX && tile.x <= seen.maxX && tile.y >= seen.minY && tile.y <= seen.maxY) {
+ failedTile = !updateOne(index);
+ }
+ }
+ } else if (hasView) {
+ for (int z = plan.minZoom; z <= plan.maxZoom && !stopNow() && !failedTile; z++) {
+ const TileRange area = tileRange(plan.box, z);
+ const TileRange seen = tileRange(view, z);
+ for (int y = std::max(area.minY, seen.minY); y <= std::min(area.maxY, seen.maxY) && !stopNow() && !failedTile; y++) {
+ for (int x = std::max(area.minX, seen.minX); x <= std::min(area.maxX, seen.maxX) && !stopNow() && !failedTile; x++) {
+ failedTile = !updateOne(indexOfTile(plan, { z, x, y }));
+ }
+ }
+ }
+ }
+ for (int64_t index = 0; index < progress.total && !stopNow() && !failedTile; index++) {
+ failedTile = !updateOne(index);
+ }
+ giveClient(connection);
+ bodies[0].release();
+ freeOutlineTiles();
+
+ if (progress.done >= progress.total) {
+ snprintf(target.version, sizeof(target.version), "%s", version);
+ time_t now = time(nullptr);
+ struct tm local {};
+ localtime_r(&now, &local);
+ strftime(target.date, sizeof(target.date), "%Y-%m-%d", &local);
+ writeMeta(target);
+ store->setRegionVersion(target.name, target.version);
+ RemoveJob done {};
+ snprintf(done.path, sizeof(done.path), "%s", progressFile.path);
+ io->run(IoPriority::Background, removeJob, &done);
+ char message[64];
+ snprintf(message, sizeof(message), "%s updated", target.name);
+ setStatus(State::Done, message);
+ } else {
+ progressFile.write = true;
+ io->run(IoPriority::Background, progressJob, &progressFile);
+ if (failedTile) {
+ setStatus(State::Failed, "Update failed, it continues later");
+ } else {
+ // Paused by input: no message, the pill just goes
+ setStatus(State::Idle, "");
+ }
+ }
+ largeFree(progress.bits);
+}
+
+void Downloader::runDelete() {
+ setStatus(State::Deleting, "Deleting");
+ if (strcmp(current.region, BROWSE_REGION) == 0) {
+ // Browsing starts the region again with its next tile
+ browseMetaReady = false;
+ }
+ RegionMeta target {};
+ readMeta(current.region, target);
+ snprintf(target.name, sizeof(target.name), "%s", current.region);
+
+ // Other regions keep the tiles they skipped because this region had them
+ auto* others = static_cast(largeAlloc(sizeof(RegionMeta) * TileStore::MAX_REGIONS));
+ auto* columns = static_cast(largeAlloc(sizeof(int) * MAX_DELETE_COLUMNS));
+ if (others == nullptr || columns == nullptr) {
+ largeFree(others);
+ largeFree(columns);
+ setStatus(State::Failed, "Out of memory");
+ return;
+ }
+ int otherCount = 0;
+ for (int i = 0; i < store->regionCount() && otherCount < TileStore::MAX_REGIONS; i++) {
+ const char* name = store->regionName(i);
+ if (strcmp(name, target.name) != 0 && readMeta(name, others[otherCount])) {
+ snprintf(others[otherCount].name, sizeof(others[otherCount].name), "%s", name);
+ otherCount++;
+ }
+ }
+
+ // One column folder per job, so the tiles on screen are read in between
+ for (int z = 0; z <= MAX_SOURCE_ZOOM && !cancelRequested; z++) {
+ ColumnsJob list {};
+ snprintf(list.folder, sizeof(list.folder), "%s/maps/%s/%d", rootPath, target.name, z);
+ list.columns = columns;
+ if (!io->run(IoPriority::Background, listColumnsJob, &list)) {
+ continue;
+ }
+ for (int i = 0; i < list.count && !cancelRequested; i++) {
+ DeleteColumnJob job { rootPath, target.name, z, columns[i], others, otherCount };
+ io->run(IoPriority::Background, deleteColumnJob, &job);
+ }
+ RemoveJob folder {};
+ snprintf(folder.path, sizeof(folder.path), "%s", list.folder);
+ folder.folder = true;
+ io->run(IoPriority::Background, removeJob, &folder);
+ }
+ largeFree(others);
+ largeFree(columns);
+ RemoveJob metaFile {};
+ snprintf(metaFile.path, sizeof(metaFile.path), "%s/maps/%s/meta.json", rootPath, target.name);
+ io->run(IoPriority::Background, removeJob, &metaFile);
+ RemoveJob folder {};
+ snprintf(folder.path, sizeof(folder.path), "%s/maps/%s", rootPath, target.name);
+ folder.folder = true;
+ io->run(IoPriority::Background, removeJob, &folder);
+ allTilesChanged = true;
+ tilesChanged = true;
+ setStatus(State::Done, cancelRequested ? "Delete stopped" : "Region deleted");
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Downloader.h b/Apps/Maps/main/Source/app/Downloader.h
new file mode 100644
index 0000000..6fd74d5
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Downloader.h
@@ -0,0 +1,274 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "IoTask.h"
+
+#include "../render/PreparedTile.h"
+#include "../tiles/ChunkedBody.h"
+#include "../tiles/RegionMeta.h"
+#include "../tiles/TileStore.h"
+
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+namespace maps {
+
+/**
+ * Downloads the tiles of a region from OpenFreeMap in the background, and deletes regions.
+ * The tile URL comes from OpenFreeMap's TileJSON. Two connections run at the same time, each kept alive
+ * across tiles. A tile is received into PSRAM and written by the I/O task, after the tiles on screen.
+ * Progress is saved in the region's meta.json, so a download continues after a restart.
+ *
+ * While browsing, a third connection fetches the tiles on screen that have no detail on the card yet into the
+ * region "browsed", one tile at a time and only while Wi-Fi is connected.
+ *
+ * A TLS handshake costs 2-3.6 s of CPU (the firmware cannot resume sessions), so connections are pooled: a
+ * finished download or browse run leaves its connection open for the next request of any kind, and one left
+ * unused for 60 s is closed. New connections are opened one at a time.
+ *
+ * Regions whose tiles come from an older OpenFreeMap version are updated while the map is left alone: one tile at a
+ * time over one connection, the tiles in view first, progress kept in the region's update.bin, any input pauses it.
+ */
+class Downloader {
+public:
+ static constexpr int CONNECTIONS = 2;
+ /**
+ * Network tasks run on core 1. While a TLS handshake (2-3 s of CPU) ran on core 0, the map loop and the render
+ * lane there made no progress for its whole length (docs/upstream/https-stall.md).
+ */
+ static constexpr BaseType_t NETWORK_CORE = 1;
+ /** Whether a TLS handshake is running on the network core now; the render lane there waits for it. */
+ static bool isHandshaking() { return handshakes.load() > 0; }
+
+ enum class State : uint8_t {
+ Idle,
+ Connecting,
+ Downloading,
+ Updating,
+ Deleting,
+ Done,
+ Failed
+ };
+
+ struct Status {
+ State state;
+ char region[REGION_NAME_MAX + 1];
+ int64_t done;
+ int64_t total;
+ uint64_t bytes;
+ char message[64];
+ };
+
+ bool start(const char* root, TileStore* store, IoTask* io);
+ void stop();
+
+ /** Starts or continues the download of a region whose meta.json exists, pausing a running job. */
+ bool download(const char* region);
+ /** Deletes a region, keeping the tiles another region still needs. */
+ bool remove(const char* region);
+ /** Brings a region to the newest tile version now, without waiting for idle time (Regions, Update now). */
+ bool update(const char* region);
+ /**
+ * Whether idle-time updates may run: the map was left alone long enough, Wi-Fi and power allow it. Set to false on
+ * any input, a running idle update stops after the tile it is fetching and continues later.
+ */
+ void setUpdatesAllowed(bool value) { updatesAllowed = value; }
+ /** The area on screen, so updates fetch the tiles the user sees first. */
+ void setView(const BoundingBox& box);
+ /** The region catalog file (in the app's assets), for regions that follow a catalog outline. */
+ void setCatalogPath(const char* path);
+ /** Asked for a free PSRAM block of bytes when a large tile does not fit; returns whether it made one. */
+ using MemoryRelief = bool (*)(void* context, size_t bytes);
+ void setMemoryRelief(MemoryRelief relief, void* context) {
+ memoryRelief = relief;
+ memoryReliefContext = context;
+ }
+ void cancel() { cancelRequested = true; }
+
+ Status status();
+ bool isBusy() const { return busy.load(); }
+ /** The tile set version OpenFreeMap serves now, empty until a download asked for it. */
+ const char* latestVersion() const { return version; }
+ /** True once after tiles were added to or removed from the card. */
+ /**
+ * Whether tiles were stored or removed since the last call. count is the number of tiles in tiles (fetched while
+ * browsing, at most MAX_ARRIVED), or -1 when any tile may have changed.
+ */
+ bool takeTilesChanged(TileKey* tiles, int& count);
+ static constexpr int MAX_ARRIVED = 8;
+
+ /** Fetches these tiles soon, after the ones already asked for, unless the server had no data for them. */
+ void browse(const TileKey* tiles, int count);
+ /**
+ * Drops queued browse tiles the view no longer needs: those at another zoom than sourceZoom or outside columns
+ * minX..maxX (a range that wraps when minX > maxX) and rows minY..maxY. The tile being fetched is left to finish.
+ */
+ void keepBrowse(int sourceZoom, int minX, int minY, int maxX, int maxY);
+ /**
+ * Asks OpenFreeMap for its TileJSON now, while Wi-Fi is up. The first connection of a session pauses the
+ * device for about 1.5 s, better right after the first view than when the user pans into new ground.
+ */
+ void prepareBrowsing();
+ /** Whether tiles for the screen are being fetched. */
+ bool isBrowsing() const { return browsing.load(); }
+ /** Tiles fetched and asked for since the browse queue was last empty, for "Fetching detail 2/4". */
+ void browseProgress(int& done, int& total);
+ static bool isOnline();
+ /** Idle: no input for a while. Downloaded tiles are only written to the card while idle. */
+ void setIdle(bool value) { idle = value; }
+ TaskHandle_t browseTaskHandle() const { return browseTask; }
+ TaskHandle_t controlTaskHandle() const { return controlTask; }
+ /** Logs the phases of the first TileJSON request (developer option). */
+ void setTiming(bool value) { timing = value; }
+
+private:
+ enum class Job : uint8_t {
+ None,
+ Download,
+ Update,
+ Delete
+ };
+
+ struct Connection {
+ Downloader* owner;
+ int index;
+ void* client;
+ ChunkedBody* body;
+ uint64_t received;
+ TaskHandle_t task;
+ char lastFolder[PATH_MAX_LENGTH];
+ /** When the request now running started, and how long its TCP and TLS connect took (0: reused). */
+ uint32_t requestStartMs = 0;
+ uint32_t connectMs = 0;
+ /** Whether the client has an open connection the next request can reuse. */
+ bool connected = false;
+ /** Whether this connection holds the handshake lock. */
+ bool handshaking = false;
+ };
+
+ struct IdleClient {
+ void* client;
+ uint32_t sinceMs;
+ };
+
+ static esp_err_t onHttpEvent(esp_http_client_event_t* event);
+ /** A keep-alive HTTPS client for OpenFreeMap whose events go to connection. */
+ static void* createClient(Connection* connection);
+ /** Runs the request set up on connection's client, and logs the TLS connection if it had to open one. */
+ esp_err_t perform(Connection& connection);
+ void beginHandshake(Connection& connection);
+ void endHandshake(Connection& connection);
+ /** Gives connection an open pooled client, or a new one. */
+ void takeClient(Connection& connection);
+ /** Returns connection's client to the pool when it is still connected, otherwise closes it. */
+ void giveClient(Connection& connection);
+ static void dropClient(Connection& connection);
+ void closeIdleClients(uint32_t maxIdleMs);
+ bool queue(Job next, const char* region);
+ static void controlMain(void* context);
+ static void connectionMain(void* context);
+ void controlLoop();
+ void runDownload();
+ void runDelete();
+ /** Updates one region whose tiles are older than the source; manual: not paused by input. */
+ void runUpdate(bool manual);
+ /** The name of a complete region with an older tile version than the source, or false. */
+ bool findOutdated(char* region, size_t size);
+ void noteArrived(const TileKey& tile);
+ /**
+ * For a region with a catalog outline: the list of its tiles, zoom by zoom, row by row, made once per job from the
+ * catalog so the order never changes. Without an outline the tiles come from the plan's rectangle.
+ */
+ bool loadOutlineTiles(const RegionMeta& region);
+ void freeOutlineTiles();
+ bool tileForIndex(const RegionMeta& region, int64_t index, TileKey& tile) const;
+ /** The nearest city, town or village to the region's centre, from its own z12 or z10 tile. */
+ bool findPlaceName(const RegionMeta& region, char* out, size_t size);
+ void connectionLoop(Connection& connection);
+ bool fetchTileJson(Connection& connection);
+ bool downloadTile(Connection& connection, const char* region, const TileKey& tile, int& status);
+ bool tileOnCard(const char* region, const TileKey& tile, bool& inThisRegion);
+ bool ensureTileJson(Connection& connection);
+ static void browseMain(void* context);
+ void browseLoop();
+ bool fetchBrowseTile(const TileKey& tile);
+ bool ensureBrowseRegion(const TileKey& tile);
+ void saveProgress();
+ bool readMeta(const char* region, RegionMeta& out);
+ bool writeMeta(const RegionMeta& out);
+ void setStatus(State state, const char* message);
+ bool claimIndex(int64_t& index, int connectionIndex);
+ void finishIndex(int connectionIndex);
+
+ char rootPath[PATH_MAX_LENGTH] = "";
+ TileStore* store = nullptr;
+ IoTask* io = nullptr;
+ SemaphoreHandle_t mutex = nullptr;
+ SemaphoreHandle_t wake = nullptr;
+ SemaphoreHandle_t stopped = nullptr;
+ TaskHandle_t controlTask = nullptr;
+ std::atomic stopRequested = false;
+ std::atomic cancelRequested = false;
+ std::atomic busy = false;
+ std::atomic tilesChanged = false;
+ std::atomic allTilesChanged = false;
+ TileKey arrived[MAX_ARRIVED] {};
+ int arrivedCount = 0;
+ Job job = Job::None;
+ char jobRegion[REGION_NAME_MAX + 1] = "";
+ Status current {};
+ RegionMeta meta {};
+ char urlTemplate[160] = "";
+ char version[48] = "";
+ Connection connections[CONNECTIONS] {};
+ ChunkedBody bodies[CONNECTIONS];
+ SemaphoreHandle_t tileJsonLock = nullptr;
+ SemaphoreHandle_t browseWake = nullptr;
+ SemaphoreHandle_t browseStopped = nullptr;
+ TaskHandle_t browseTask = nullptr;
+ static constexpr int BROWSE_QUEUE = 16;
+ static constexpr int BROWSE_EMPTY = 64;
+ TileKey browseQueue[BROWSE_QUEUE] {};
+ int browseCount = 0;
+ int browseDone = 0;
+ int browseTotal = 0;
+ /** Tiles the server had no data for, the sea or empty land, so they are not asked for again. */
+ TileKey browseEmpty[BROWSE_EMPTY] {};
+ int browseEmptyCount = 0;
+ int browseEmptyNext = 0;
+ std::atomic browsing = false;
+ std::atomic idle = true;
+ std::atomic warmUp = false;
+ bool timing = false;
+ Connection browseConnection {};
+ ChunkedBody browseBody;
+ MemoryRelief memoryRelief = nullptr;
+ void* memoryReliefContext = nullptr;
+ RegionMeta browseMeta {};
+ bool browseMetaReady = false;
+ int browseUnsaved = 0;
+ SemaphoreHandle_t connectionsDone = nullptr;
+ int64_t nextIndex = 0;
+ int64_t activeIndex[CONNECTIONS] {};
+ int64_t sinceSave = 0;
+ std::atomic failed = false;
+ static constexpr int IDLE_CLIENTS = CONNECTIONS + 1;
+ IdleClient idleClients[IDLE_CLIENTS] {};
+ int idleCount = 0;
+ SemaphoreHandle_t handshakeLock = nullptr;
+ static std::atomic handshakes;
+ std::atomic updatesAllowed = false;
+ BoundingBox viewBox {};
+ bool viewKnown = false;
+ uint32_t lastUpdateCheckMs = 0;
+ char catalogPath[PATH_MAX_LENGTH] = "";
+ TileKey* outlineTiles = nullptr;
+ int64_t outlineCount = 0;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/GpxImport.cpp b/Apps/Maps/main/Source/app/GpxImport.cpp
new file mode 100644
index 0000000..0ff0b25
--- /dev/null
+++ b/Apps/Maps/main/Source/app/GpxImport.cpp
@@ -0,0 +1,209 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "GpxImport.h"
+
+#include "../platform/Platform.h"
+#include "../render/PreparedTile.h"
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+constexpr uint32_t TASK_STACK = 6144;
+constexpr UBaseType_t TASK_PRIORITY = 2;
+constexpr size_t CHUNK = 8192;
+
+struct ChunkJob {
+ const char* path;
+ long offset;
+ char* buffer;
+ size_t count;
+ long fileSize;
+};
+
+bool readChunkJob(IoTask& io, void* context) {
+ (void)io;
+ auto* job = static_cast(context);
+ FILE* file = fopen(job->path, "rb");
+ if (file == nullptr) {
+ return false;
+ }
+ bool ok = true;
+ if (job->fileSize < 0) {
+ ok = fseek(file, 0, SEEK_END) == 0;
+ job->fileSize = ok ? ftell(file) : -1;
+ }
+ ok = ok && fseek(file, job->offset, SEEK_SET) == 0;
+ job->count = ok ? fread(job->buffer, 1, CHUNK, file) : 0;
+ fclose(file);
+ return ok;
+}
+
+struct CacheJob {
+ char path[PATH_MAX_LENGTH + 8];
+ FileBuffer* buffer;
+ const ByteWriter* data;
+};
+
+bool readCacheJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ return io.readFile(job->path, *job->buffer);
+}
+
+bool writeCacheJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ char temporary[PATH_MAX_LENGTH + 16];
+ snprintf(temporary, sizeof(temporary), "%s.tmp", job->path);
+ return io.writeFile(job->path, temporary, job->data->bytes(), job->data->size(), false);
+}
+
+} // namespace
+
+GpxImport::~GpxImport() {
+ // The map ends the app only when no import runs, see MapView::end()
+ if (layer != nullptr) {
+ layer->~GpxLayer();
+ largeFree(layer);
+ }
+}
+
+bool GpxImport::start(IoTask& ioTask, const char* path) {
+ if (currentState != State::Idle) {
+ return false;
+ }
+ io = &ioTask;
+ snprintf(filePath, sizeof(filePath), "%s", path);
+ message[0] = '\0';
+ currentState = State::Busy;
+ if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-gpx", TASK_STACK, this, TASK_PRIORITY, &task, 1, MALLOC_CAP_SPIRAM) != pdPASS) {
+ currentState = State::Idle;
+ return false;
+ }
+ return true;
+}
+
+void GpxImport::taskMain(void* context) {
+ auto* import = static_cast(context);
+ import->run();
+ vTaskSuspend(nullptr);
+}
+
+void GpxImport::run() {
+ void* memory = largeAlloc(sizeof(GpxLayer));
+ layer = memory != nullptr ? new (memory) GpxLayer() : nullptr;
+ if (layer == nullptr) {
+ snprintf(message, sizeof(message), "Not enough memory");
+ currentState = State::Failed;
+ return;
+ }
+ // The cache sits next to the file: walk.gpx -> walk.gpc
+ CacheJob cache {};
+ snprintf(cache.path, sizeof(cache.path), "%s", filePath);
+ const size_t length = strlen(cache.path);
+ if (length > 4) {
+ cache.path[length - 1] = 'c';
+ }
+ char* buffer = static_cast(largeAlloc(CHUNK));
+ if (buffer == nullptr) {
+ snprintf(message, sizeof(message), "Not enough memory");
+ currentState = State::Failed;
+ return;
+ }
+ ChunkJob chunk { filePath, 0, buffer, 0, -1 };
+ if (!io->run(IoPriority::Background, readChunkJob, &chunk) || chunk.fileSize < 0) {
+ largeFree(buffer);
+ snprintf(message, sizeof(message), "Cannot read the file");
+ currentState = State::Failed;
+ return;
+ }
+ const auto sourceSize = static_cast(chunk.fileSize);
+ FileBuffer cached;
+ cache.buffer = &cached;
+ if (io->run(IoPriority::Background, readCacheJob, &cache) && layer->load(cached.bytes(), cached.size(), sourceSize)) {
+ largeFree(buffer);
+ logInfo("GPX %s from its cache: %d lines, %d waypoints", filePath, layer->lineCount(), layer->pinCount());
+ currentState = State::Done;
+ return;
+ }
+ // No current cache: the file is read once, in chunks, with the card free for the map in between
+ void* builderMemory = largeAlloc(sizeof(GpxCacheBuilder));
+ auto* builder = builderMemory != nullptr ? new (builderMemory) GpxCacheBuilder() : nullptr;
+ bool ok = builder != nullptr;
+ const uint32_t start = nowMs();
+ while (ok && chunk.count > 0) {
+ ok = builder->feed(buffer, chunk.count);
+ chunk.offset += static_cast(chunk.count);
+ if (ok && chunk.offset < chunk.fileSize) {
+ ok = io->run(IoPriority::Background, readChunkJob, &chunk);
+ } else {
+ chunk.count = 0;
+ }
+ vTaskDelay(1);
+ }
+ largeFree(buffer);
+ ByteWriter data;
+ const bool built = builder != nullptr && builder->finish(sourceSize, data);
+ if (!built) {
+ snprintf(message, sizeof(message), "%s", builder == nullptr ? "Not enough memory" : builder->error());
+ } else {
+ logInfo("GPX %s read in %u ms: %d of %d points kept", filePath, nowMs() - start, builder->pointsKept(), builder->pointsRead());
+ }
+ if (builder != nullptr) {
+ builder->~GpxCacheBuilder();
+ largeFree(builder);
+ }
+ if (!built || !layer->load(data.bytes(), data.size(), sourceSize)) {
+ if (message[0] == '\0') {
+ snprintf(message, sizeof(message), "Not a usable GPX file");
+ }
+ currentState = State::Failed;
+ return;
+ }
+ cache.data = &data;
+ if (!io->run(IoPriority::Background, writeCacheJob, &cache)) {
+ // Only slower next time
+ logError("Could not write %s", cache.path);
+ }
+ currentState = State::Done;
+}
+
+GpxLayer* GpxImport::takeLayer() {
+ GpxLayer* result = currentState == State::Done ? layer : nullptr;
+ if (result != nullptr) {
+ layer = nullptr;
+ }
+ acknowledge();
+ return result;
+}
+
+void GpxImport::acknowledge() {
+ if (currentState == State::Done || currentState == State::Failed) {
+ if (task != nullptr) {
+ // The state is set just before the task suspends itself
+ TaskStatus_t status {};
+ for (int i = 0; i < 100; i++) {
+ vTaskGetInfo(task, &status, pdFALSE, eInvalid);
+ if (status.eCurrentState == eSuspended) {
+ break;
+ }
+ vTaskDelay(pdMS_TO_TICKS(5));
+ }
+ vTaskDeleteWithCaps(task);
+ task = nullptr;
+ }
+ if (layer != nullptr) {
+ layer->~GpxLayer();
+ largeFree(layer);
+ layer = nullptr;
+ }
+ currentState = State::Idle;
+ }
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/GpxImport.h b/Apps/Maps/main/Source/app/GpxImport.h
new file mode 100644
index 0000000..d519357
--- /dev/null
+++ b/Apps/Maps/main/Source/app/GpxImport.h
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "IoTask.h"
+
+#include "../tiles/TilePaths.h"
+#include "../ui/GpxLayer.h"
+
+#include
+#include
+
+#include
+
+namespace maps {
+
+/**
+ * Reads one GPX file at a time in the background into a GpxLayer: from its .gpc cache next to it when that is
+ * current, otherwise by parsing the GPX in 8 KB chunks through the I/O task and writing a new cache. The map polls
+ * for the result and takes the layer.
+ */
+class GpxImport {
+public:
+ enum class State : uint8_t {
+ Idle,
+ Busy,
+ Done,
+ Failed
+ };
+
+ ~GpxImport();
+ /** Starts reading path; false while another file is still being read. */
+ bool start(IoTask& io, const char* path);
+ State state() const { return currentState.load(); }
+ /** After Done: the layer, now owned by the caller; after Failed: why. Both reset the state to Idle. */
+ GpxLayer* takeLayer();
+ const char* error() const { return message; }
+ void acknowledge();
+ const char* path() const { return filePath; }
+
+private:
+ static void taskMain(void* context);
+ void run();
+ void finish();
+
+ IoTask* io = nullptr;
+ TaskHandle_t task = nullptr;
+ std::atomic currentState = State::Idle;
+ char filePath[PATH_MAX_LENGTH] = "";
+ char message[64] = "";
+ GpxLayer* layer = nullptr;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Input.cpp b/Apps/Maps/main/Source/app/Input.cpp
new file mode 100644
index 0000000..52ccdf8
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Input.cpp
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "Input.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+constexpr uint32_t RESCAN_INTERVAL_MS = 500;
+
+struct Collect {
+ Device** devices;
+ int* count;
+};
+
+bool collectDevice(Device* device, void* context) {
+ auto* collect = static_cast(context);
+ if (*collect->count >= Input::MAX_DEVICES || !device_is_ready(device)) {
+ return true;
+ }
+ for (int i = 0; i < *collect->count; i++) {
+ if (collect->devices[i] == device) {
+ return true;
+ }
+ }
+ if (device_get(device) == ERROR_NONE) {
+ collect->devices[(*collect->count)++] = device;
+ }
+ return true;
+}
+
+} // namespace
+
+Input::~Input() {
+ end();
+}
+
+void Input::begin() {
+ rescan();
+ devicesChanged = false;
+ if (device_get_first_active_by_type(&TOUCH_TYPE, &touch) != ERROR_NONE) {
+ touch = nullptr;
+ }
+ logInfo("Input: %d keyboards, %d mice, %s", keyboardCount, mouseCount, touch != nullptr ? "touch" : "no touch");
+}
+
+void Input::end() {
+ for (int i = 0; i < keyboardCount; i++) {
+ device_put(keyboards[i]);
+ }
+ keyboardCount = 0;
+ for (int i = 0; i < mouseCount; i++) {
+ device_put(mice[i]);
+ }
+ mouseCount = 0;
+ if (touch != nullptr) {
+ device_put(touch);
+ touch = nullptr;
+ }
+}
+
+int Input::dropStopped(Device** devices, int& count) {
+ int dropped = 0;
+ for (int i = 0; i < count;) {
+ if (device_is_ready(devices[i])) {
+ i++;
+ continue;
+ }
+ logInfo("Input: %s stopped", devices[i]->name != nullptr ? devices[i]->name : "a device");
+ device_put(devices[i]);
+ devices[i] = devices[--count];
+ dropped++;
+ }
+ return dropped;
+}
+
+void Input::rescan() {
+ lastScanMs = nowMs();
+ const int keyboardsBefore = keyboardCount;
+ const int miceBefore = mouseCount;
+ const int dropped = dropStopped(keyboards, keyboardCount) + dropStopped(mice, mouseCount);
+ Collect keyboardCollect { keyboards, &keyboardCount };
+ device_for_each_of_type(&KEYBOARD_TYPE, &keyboardCollect, collectDevice);
+ Collect mouseCollect { mice, &mouseCount };
+ device_for_each_of_type(&MOUSE_TYPE, &mouseCollect, collectDevice);
+ if (dropped > 0 || keyboardCount != keyboardsBefore || mouseCount != miceBefore) {
+ devicesChanged = true;
+ logInfo("Input: %d keyboards, %d mice", keyboardCount, mouseCount);
+ }
+}
+
+void Input::refreshIfDue() {
+ if (nowMs() - lastScanMs > RESCAN_INTERVAL_MS) {
+ rescan();
+ }
+}
+
+bool Input::takeDevicesChanged() {
+ const bool changed = devicesChanged;
+ devicesChanged = false;
+ return changed;
+}
+
+int Input::readKeys(KeyEvent* events, int maxEvents) {
+ refreshIfDue();
+ int count = 0;
+ for (int i = 0; i < keyboardCount && count < maxEvents; i++) {
+ if (!device_is_ready(keyboards[i])) {
+ continue;
+ }
+ KeyboardKeyData data;
+ while (count < maxEvents && keyboard_read_key(keyboards[i], &data) == ERROR_NONE && data.key != 0) {
+ events[count++] = { data.key, data.pressed };
+ if (!data.continue_reading) {
+ break;
+ }
+ }
+ }
+ return count;
+}
+
+bool Input::readMouse(int32_t& dx, int32_t& dy) {
+ refreshIfDue();
+ dx = 0;
+ dy = 0;
+ bool pressed = false;
+ for (int i = 0; i < mouseCount; i++) {
+ if (!device_is_ready(mice[i])) {
+ continue;
+ }
+ int32_t x = 0;
+ int32_t y = 0;
+ if (mouse_read_delta(mice[i], &x, &y) == ERROR_NONE) {
+ dx += x;
+ dy += y;
+ }
+ bool button = false;
+ if (mouse_get_button_count(mice[i]) > 0 && mouse_get_button_pressed(mice[i], 0, &button) == ERROR_NONE && button) {
+ pressed = true;
+ }
+ }
+ return pressed;
+}
+
+bool Input::readTouch(TouchState& state) {
+ state.count = 0;
+ if (touch == nullptr || touch_read_data(touch, 0) != ERROR_NONE) {
+ return false;
+ }
+ uint16_t strength[2];
+ uint8_t count = 0;
+ if (!touch_get_touched_points(touch, state.x, state.y, strength, &count, 2)) {
+ return false;
+ }
+ state.count = count > 2 ? 2 : count;
+ return state.count > 0;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/Input.h b/Apps/Maps/main/Source/app/Input.h
new file mode 100644
index 0000000..ba5d5b8
--- /dev/null
+++ b/Apps/Maps/main/Source/app/Input.h
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+
+#include
+
+namespace maps {
+
+struct KeyEvent {
+ uint32_t key;
+ bool pressed;
+};
+
+struct TouchState {
+ uint8_t count;
+ uint16_t x[2];
+ uint16_t y[2];
+};
+
+/**
+ * Reads keyboards (the T-Deck keyboard and a trackball in keyboard mode), mice (a trackball in mouse mode) and
+ * the touch screen directly from their drivers while LVGL is stopped.
+ *
+ * A trackball driver replaces its child device when its mode changes: the keyboard one is stopped and removed, a
+ * mouse one is added, or the other way round. The device lists are refreshed twice a second, devices that stopped
+ * are let go at once (a held reference keeps the driver from destroying the old child), and new ones are taken.
+ */
+class Input {
+public:
+ static constexpr int MAX_DEVICES = 4;
+
+ ~Input();
+
+ void begin();
+ void end();
+
+ /** Returns the number of key events written to events. */
+ int readKeys(KeyEvent* events, int maxEvents);
+ /** Sums the movement of all mice since the last call. Returns true while a mouse button is held. */
+ bool readMouse(int32_t& dx, int32_t& dy);
+ bool readTouch(TouchState& state);
+ bool hasTouch() const { return touch != nullptr; }
+ bool hasKeyboard() const { return keyboardCount > 0; }
+ bool hasPointer() const { return mouseCount > 0; }
+ /** True once after a keyboard or mouse came or went, so the overlay can adapt. */
+ bool takeDevicesChanged();
+
+private:
+ void rescan();
+ void refreshIfDue();
+ /** Lets go of devices that stopped. Returns how many. */
+ static int dropStopped(Device** devices, int& count);
+
+ Device* keyboards[MAX_DEVICES] {};
+ int keyboardCount = 0;
+ Device* mice[MAX_DEVICES] {};
+ int mouseCount = 0;
+ Device* touch = nullptr;
+ uint32_t lastScanMs = 0;
+ bool devicesChanged = false;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/IoTask.cpp b/Apps/Maps/main/Source/app/IoTask.cpp
new file mode 100644
index 0000000..65ffc51
--- /dev/null
+++ b/Apps/Maps/main/Source/app/IoTask.cpp
@@ -0,0 +1,244 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "IoTask.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+/** Jobs run on this stack: folder walks, meta.json text (1 KB) parsed with sscanf, FatFs long names, paths. */
+constexpr uint32_t TASK_STACK = 8192;
+/**
+ * The same as the app task and the render lanes. The SD card driver keeps the CPU busy while it transfers,
+ * and on the T-Deck the display shares the SPI bus, so a higher priority starved drawing (frames of 1-3 s).
+ */
+constexpr UBaseType_t TASK_PRIORITY = 4;
+/** Longer jobs are logged, they hold up everything behind them. */
+constexpr uint32_t SLOW_JOB_MS = 300;
+
+} // namespace
+
+bool IoTask::start() {
+ mutex = xSemaphoreCreateMutex();
+ wake = xSemaphoreCreateCounting(64, 0);
+ stopped = xSemaphoreCreateBinary();
+ if (mutex == nullptr || wake == nullptr || stopped == nullptr) {
+ return false;
+ }
+ stopRequested = false;
+ // Not pinned, so it never takes a core from one render lane only
+ if (xTaskCreatePinnedToCore(taskMain, "maps-io", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY) != pdPASS) {
+ task = nullptr;
+ return false;
+ }
+ runningTask = true;
+ // Job addresses in the log are matched against the ELF's symbols through this one
+ logInfo("I/O task started, taskMain at %p", reinterpret_cast(&IoTask::taskMain));
+ return true;
+}
+
+void IoTask::stop() {
+ if (task != nullptr) {
+ stopRequested = true;
+ xSemaphoreGive(wake);
+ xSemaphoreTake(stopped, portMAX_DELAY);
+ task = nullptr;
+ }
+ runningTask = false;
+ for (SemaphoreHandle_t* handle : { &mutex, &wake, &stopped }) {
+ if (*handle != nullptr) {
+ vSemaphoreDelete(*handle);
+ *handle = nullptr;
+ }
+ }
+}
+
+void IoTask::taskMain(void* context) {
+ auto* io = static_cast(context);
+ io->loop();
+ xSemaphoreGive(io->stopped);
+ vTaskDelete(nullptr);
+}
+
+bool IoTask::run(IoPriority priority, IoJob job, void* context) {
+ if (!runningTask) {
+ return false;
+ }
+ SemaphoreHandle_t done = xSemaphoreCreateBinary();
+ if (done == nullptr) {
+ return false;
+ }
+ Request request { job, context, done, false, nowMs(), prefetchGeneration.load() };
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool queued = queue.push(&request, priority);
+ xSemaphoreGive(mutex);
+ if (!queued) {
+ vSemaphoreDelete(done);
+ logError("I/O queue full");
+ return false;
+ }
+ xSemaphoreGive(wake);
+ xSemaphoreTake(done, portMAX_DELAY);
+ vSemaphoreDelete(done);
+ return request.result;
+}
+
+void IoTask::waitWhileHeld() {
+ // A frame goes to the display in strips, each of which waited for a whole card chunk: 150-250 ms per frame
+ for (int wait = 0; held && !stopRequested && wait < MAX_HOLD_MS / 5; wait++) {
+ vTaskDelay(pdMS_TO_TICKS(5));
+ }
+}
+
+bool IoTask::shouldYield() const {
+ if (running == IoPriority::Visible) {
+ return false;
+ }
+ if (running == IoPriority::Prefetch && runningGeneration != prefetchGeneration.load()) {
+ return true;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const bool urgent = queue.hasMoreUrgent(running);
+ xSemaphoreGive(mutex);
+ return urgent;
+}
+
+void IoTask::loop() {
+ while (true) {
+ xSemaphoreTake(wake, portMAX_DELAY);
+ while (true) {
+ Request* request = nullptr;
+ IoPriority priority = IoPriority::Background;
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ queue.pop(request, &priority);
+ xSemaphoreGive(mutex);
+ if (request == nullptr) {
+ break;
+ }
+ waitWhileHeld();
+ if (stopRequested) {
+ request->result = false;
+ } else {
+ running = priority;
+ runningGeneration = request->prefetchGeneration;
+ runningYielded = false;
+ if (priority == IoPriority::Visible) {
+ const uint32_t waited = nowMs() - request->queuedAt;
+ longestVisibleWait = waited > longestVisibleWait ? waited : longestVisibleWait;
+ }
+ // A prefetch cancelled while it waited does not start
+ if (priority == IoPriority::Prefetch && runningGeneration != prefetchGeneration.load()) {
+ runningYielded = true;
+ request->result = false;
+ } else {
+ const uint32_t started = nowMs();
+ request->result = request->job(*this, request->context);
+ const uint32_t took = nowMs() - started;
+ if (took > SLOW_JOB_MS) {
+ logInfo("Slow I/O job %u ms (priority %d, waited %u ms, job %p)", took, static_cast(priority), started - request->queuedAt,
+ reinterpret_cast(request->job));
+ }
+ }
+ running = IoPriority::Background;
+ }
+ xSemaphoreGive(request->done);
+ }
+ if (stopRequested) {
+ break;
+ }
+ }
+}
+
+bool IoTask::readFile(const char* path, FileBuffer& buffer) {
+ buffer.clear();
+ FILE* file = fopen(path, "rb");
+ if (file == nullptr) {
+ return false;
+ }
+ bool ok = fseek(file, 0, SEEK_END) == 0;
+ const long size = ok ? ftell(file) : -1;
+ ok = ok && size > 0 && fseek(file, 0, SEEK_SET) == 0 && buffer.reserve(static_cast(size));
+ size_t done = 0;
+ while (ok && done < static_cast(size)) {
+ if (done > 0 && shouldYield()) {
+ markYielded();
+ ok = false;
+ break;
+ }
+ if (done > 0) {
+ // Between chunks the display gets the shared SPI bus
+ vTaskDelay(1);
+ }
+ waitWhileHeld();
+ const size_t wanted = static_cast(size) - done < CHUNK_BYTES ? static_cast(size) - done : CHUNK_BYTES;
+ const size_t count = fread(buffer.writable() + done, 1, wanted, file);
+ ok = count == wanted;
+ done += count;
+ }
+ fclose(file);
+ if (ok) {
+ buffer.setSize(done);
+ }
+ return ok;
+}
+
+bool IoTask::writeFile(const char* path, const char* temporaryPath, const uint8_t* bytes, size_t size, bool canYield) {
+ const uint8_t* parts[1] = { bytes };
+ const size_t sizes[1] = { size };
+ return writeParts(path, temporaryPath, parts, sizes, 1, canYield);
+}
+
+bool IoTask::writeFile(const char* path, const char* temporaryPath, const ChunkedBody& body, bool canYield) {
+ const uint8_t* parts[ChunkedBody::MAX_CHUNKS];
+ size_t sizes[ChunkedBody::MAX_CHUNKS];
+ const int count = body.chunkCount();
+ for (int i = 0; i < count; i++) {
+ parts[i] = body.chunk(i);
+ sizes[i] = body.chunkSize(i);
+ }
+ return writeParts(path, temporaryPath, parts, sizes, count, canYield);
+}
+
+bool IoTask::writeParts(const char* path, const char* temporaryPath, const uint8_t* const* parts, const size_t* sizes, int partCount,
+ bool canYield) {
+ FILE* file = fopen(temporaryPath, "wb");
+ if (file == nullptr) {
+ return false;
+ }
+ bool ok = true;
+ bool started = false;
+ for (int part = 0; ok && part < partCount; part++) {
+ size_t done = 0;
+ while (ok && done < sizes[part]) {
+ if (canYield && started && shouldYield()) {
+ markYielded();
+ ok = false;
+ break;
+ }
+ if (started) {
+ vTaskDelay(1);
+ }
+ waitWhileHeld();
+ const size_t count = sizes[part] - done < CHUNK_BYTES ? sizes[part] - done : CHUNK_BYTES;
+ ok = fwrite(parts[part] + done, 1, count, file) == count;
+ done += count;
+ started = true;
+ }
+ }
+ ok = fclose(file) == 0 && ok;
+ // Most files are new: the old one is only removed when the rename finds it, each FAT operation holds the card
+ if (ok && rename(temporaryPath, path) != 0) {
+ remove(path);
+ ok = rename(temporaryPath, path) == 0;
+ }
+ if (!ok) {
+ remove(temporaryPath);
+ }
+ return ok;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/IoTask.h b/Apps/Maps/main/Source/app/IoTask.h
new file mode 100644
index 0000000..dfb5966
--- /dev/null
+++ b/Apps/Maps/main/Source/app/IoTask.h
@@ -0,0 +1,104 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "../tiles/ChunkedBody.h"
+#include "../mvt/ByteInput.h"
+#include "../tiles/IoQueue.h"
+
+#include
+#include
+#include
+
+#include
+#include
+
+namespace maps {
+
+class IoTask;
+
+/** A piece of SD card work. It runs on the I/O task and returns whether it succeeded. */
+using IoJob = bool (*)(IoTask& io, void* context);
+
+/**
+ * The only task that reads or writes the SD card while the map runs. Render lanes, the downloader and the UI
+ * hand it jobs and wait for them; it runs the most urgent waiting job first. Files are read with few large
+ * reads into PSRAM buffers, and a long read or write of lower priority stops between chunks when a more
+ * urgent job arrives, so its owner can queue it again.
+ */
+class IoTask {
+public:
+ /** Small enough that the display, on the same SPI bus on the T-Deck, waits at most about 40 ms. */
+ static constexpr size_t CHUNK_BYTES = 16 * 1024;
+
+ bool start();
+ void stop();
+
+ /** Runs a job on the I/O task and waits for its result. Returns false right away when the task is not running. */
+ bool run(IoPriority priority, IoJob job, void* context);
+
+ /** For jobs: whether the running job should stop because a more urgent one waits, or prefetch was cancelled. */
+ bool shouldYield() const;
+ /**
+ * Holds back card work while true: no new job and no next chunk starts, for at most MAX_HOLD_MS. For zoom
+ * animations, whose frames otherwise wait for the card on the SPI bus the T-Deck's display shares.
+ */
+ void hold(bool value) { held = value; }
+ static constexpr int MAX_HOLD_MS = 400;
+ /** For jobs: whether the running job stopped early through shouldYield(). Reset for each job. */
+ bool yielded() const { return runningYielded; }
+ void markYielded() { runningYielded = true; }
+ IoPriority runningPriority() const { return running; }
+
+ /** Makes the running and waiting prefetch reads stop at their next chunk, after input moved the view. */
+ void cancelPrefetch() { prefetchGeneration++; }
+
+ /** For jobs: reads a whole file into buffer in large chunks. Returns false on error or when it yielded. */
+ bool readFile(const char* path, FileBuffer& buffer);
+ /** For jobs: writes bytes to path through path.tmp and a rename, in large chunks. */
+ bool writeFile(const char* path, const char* temporaryPath, const uint8_t* bytes, size_t size, bool canYield);
+ /** The same for a file in pieces. */
+ bool writeFile(const char* path, const char* temporaryPath, const ChunkedBody& body, bool canYield);
+
+ /** Statistics for the log: jobs run, and the longest wait of a visible job. */
+ uint32_t longestVisibleWaitMs() const { return longestVisibleWait; }
+ /** The fewest bytes of stack the task had left so far. */
+ TaskHandle_t taskHandle() const { return task; }
+ uint32_t stackLeft() const { return task != nullptr ? static_cast(uxTaskGetStackHighWaterMark(task)) : 0; }
+
+private:
+ bool writeParts(const char* path, const char* temporaryPath, const uint8_t* const* parts, const size_t* sizes, int partCount, bool canYield);
+ struct Request {
+ IoJob job;
+ void* context;
+ SemaphoreHandle_t done;
+ bool result;
+ uint32_t queuedAt;
+ uint32_t prefetchGeneration;
+ };
+
+ static void taskMain(void* context);
+ void loop();
+
+ TaskHandle_t task = nullptr;
+ SemaphoreHandle_t mutex = nullptr;
+ SemaphoreHandle_t wake = nullptr;
+ SemaphoreHandle_t stopped = nullptr;
+ std::atomic stopRequested = false;
+ std::atomic held = false;
+ void waitWhileHeld();
+ std::atomic runningTask = false;
+ IoQueue queue;
+ IoPriority running = IoPriority::Background;
+ uint32_t runningGeneration = 0;
+ bool runningYielded = false;
+ std::atomic prefetchGeneration = 0;
+ uint32_t longestVisibleWait = 0;
+};
+
+/** Runs a lambda on the I/O task and waits for it, for the UI's occasional file access. */
+template
+bool runOnCard(IoTask& io, Function& function, IoPriority priority = IoPriority::Visible) {
+ return io.run(priority, [](IoTask&, void* context) { return (*static_cast(context))(); }, &function);
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LabelFontMaker.cpp b/Apps/Maps/main/Source/app/LabelFontMaker.cpp
new file mode 100644
index 0000000..0cd4feb
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LabelFontMaker.cpp
@@ -0,0 +1,162 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "LabelFontMaker.h"
+
+#include "../mvt/ByteInput.h"
+#include "../platform/Platform.h"
+
+#include
+#include
+#include
+
+#include
+
+#include
+#include
+
+namespace maps {
+
+namespace {
+
+/** The stb_truetype rasteriser in the generator keeps its work areas on the stack. */
+constexpr uint32_t TASK_STACK = 12288;
+constexpr UBaseType_t TASK_PRIORITY = 1;
+/** When stop() logs that a font in progress holds it up. */
+constexpr int STOP_WAIT_MS = 6000;
+
+struct ReadJob {
+ const char* path;
+ FileBuffer* buffer;
+ bool yielded;
+};
+
+bool readJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ const bool ok = io.readFile(job->path, *job->buffer);
+ job->yielded = io.yielded();
+ return ok;
+}
+
+struct WriteJob {
+ const char* folder;
+ const char* path;
+ const uint8_t* bytes;
+ size_t size;
+};
+
+bool writeJob(IoTask& io, void* context) {
+ auto* job = static_cast(context);
+ // The folder is made on first use; it exists already afterwards
+ mkdir(job->folder, 0777);
+ char temporary[200];
+ snprintf(temporary, sizeof(temporary), "%s.tmp", job->path);
+ return io.writeFile(job->path, temporary, job->bytes, job->size, false);
+}
+
+} // namespace
+
+void LabelFontMaker::cachePath(const char* root, int i, char* out, size_t size) {
+ snprintf(out, size, "%s/fonts/lbl%db.bin", root, EM_SIZES[i]);
+}
+
+void LabelFontMaker::configure(IoTask& ioTask, const char* root, const char* ttf) {
+ io = &ioTask;
+ snprintf(rootPath, sizeof(rootPath), "%s", root);
+ snprintf(ttfPath, sizeof(ttfPath), "%s", ttf);
+}
+
+bool LabelFontMaker::start(unsigned missingMask) {
+ if (io == nullptr || task != nullptr || missingMask == 0) {
+ return false;
+ }
+ wanted = missingMask;
+ finished = false;
+ stopRequested = false;
+ written = 0;
+ if (xTaskCreatePinnedToCoreWithCaps(taskMain, "maps-fonts", TASK_STACK, this, TASK_PRIORITY, &task, tskNO_AFFINITY,
+ MALLOC_CAP_SPIRAM) != pdPASS) {
+ task = nullptr;
+ logError("Label font task not started, the map keeps the system fonts");
+ return false;
+ }
+ return true;
+}
+
+void LabelFontMaker::taskMain(void* context) {
+ auto* maker = static_cast(context);
+ maker->run();
+ maker->finished = true;
+ // Deleted by stop(), a task cannot free its own PSRAM stack
+ vTaskSuspend(nullptr);
+}
+
+void LabelFontMaker::run() {
+ const uint32_t start = nowMs();
+ FileBuffer ttf;
+ ReadJob read { ttfPath, &ttf, false };
+ bool ok = false;
+ // A background read gives way to the map's tile reads; while the map is busy it is simply tried again
+ for (int attempt = 0; attempt < 600 && !stopRequested; attempt++) {
+ ok = io->run(IoPriority::Background, readJob, &read);
+ if (ok || !read.yielded) {
+ break;
+ }
+ vTaskDelay(pdMS_TO_TICKS(50));
+ }
+ if (!ok || ttf.size() == 0) {
+ logError("Label font: cannot read %s, the map keeps the system fonts", ttfPath);
+ return;
+ }
+ char folder[180];
+ snprintf(folder, sizeof(folder), "%s/fonts", rootPath);
+ for (int i = 0; i < COUNT && !stopRequested; i++) {
+ if ((wanted & (1u << i)) == 0) {
+ continue;
+ }
+ const uint32_t fontStart = nowMs();
+ BinFontGeneratorConfig config {};
+ config.ttf_data = ttf.bytes();
+ config.ttf_size = ttf.size();
+ config.size = static_cast(EM_SIZES[i]);
+ config.bpp = 4;
+ uint8_t* data = nullptr;
+ size_t size = 0;
+ const error_t error = binfont_generate(&config, &data, &size);
+ if (error != ERROR_NONE || data == nullptr) {
+ logError("Label font %d px: generator failed with error %d, the map keeps the system font", EM_SIZES[i], static_cast(error));
+ memory_free(data);
+ continue;
+ }
+ const uint32_t generated = nowMs();
+ char path[200];
+ cachePath(rootPath, i, path, sizeof(path));
+ WriteJob write { folder, path, data, size };
+ const bool ok = io->run(IoPriority::Background, writeJob, &write);
+ memory_free(data);
+ if (ok) {
+ written = written.load() | (1u << i);
+ }
+ logInfo("Label font %d px: %u bytes, generated in %u ms, %s in %u ms", EM_SIZES[i], static_cast(size),
+ static_cast(generated - fontStart), ok ? "written" : "NOT written", static_cast(nowMs() - generated));
+ // Gives the render lanes and the UI their turn between two fonts
+ vTaskDelay(pdMS_TO_TICKS(20));
+ }
+ logInfo("Label fonts made in %u ms in total", static_cast(nowMs() - start));
+}
+
+void LabelFontMaker::stop() {
+ if (task == nullptr) {
+ return;
+ }
+ stopRequested = true;
+ // A font being made cannot be interrupted, and the task still uses this object and the I/O task: wait for it
+ for (int waited = 0; !finished.load(); waited += 10) {
+ if (waited == STOP_WAIT_MS) {
+ logError("Label font task still busy after %d ms, waiting", STOP_WAIT_MS);
+ }
+ vTaskDelay(pdMS_TO_TICKS(10));
+ }
+ vTaskDeleteWithCaps(task);
+ task = nullptr;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LabelFontMaker.h b/Apps/Maps/main/Source/app/LabelFontMaker.h
new file mode 100644
index 0000000..0d211c7
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LabelFontMaker.h
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "IoTask.h"
+
+#include
+#include
+
+#include
+#include
+
+namespace maps {
+
+/**
+ * Makes the map's label fonts once, from the small TrueType file the app ships (assets/fonts/LabelSans.ttf: Adwaita Sans
+ * with the Latin, Greek and Cyrillic letters common on maps, which Tactility's own fonts lack), with the binfont-module's binfont_generate(). The fonts are
+ * cached on the card as /fonts/lblb.bin ("b" is the format number: change it when the sizes or the
+ * subset change). A short-lived low priority task does the work, so input and the first frame are never held up; the
+ * card is only touched through the I/O task. The map loop looks at isFinished() and opens the cached files.
+ */
+class LabelFontMaker {
+public:
+ static constexpr int COUNT = 3;
+ /**
+ * Em sizes with the letter sizes of Tactility's small, default and large fonts on the T-Deck. The lines are taller
+ * (17, 21, 26 px) because the subset's tallest accents (Vietnamese, Cyrillic) count in the line height.
+ */
+ static constexpr int EM_SIZES[COUNT] = { 11, 14, 18 };
+
+ ~LabelFontMaker() { stop(); }
+
+ /** The cache file of font i. */
+ static void cachePath(const char* root, int i, char* out, size_t size);
+ /** Whether the cache folder, the ttf and the paths are set up; call before start(). */
+ void configure(IoTask& ioTask, const char* root, const char* ttfPath);
+ /** Starts making the fonts in the mask (bit i for font i); false when the task could not be started. */
+ bool start(unsigned missingMask);
+ /** True once the task is done, whether or not it succeeded; it never starts again. */
+ bool isFinished() const { return finished.load(); }
+ bool isRunning() const { return task != nullptr && !finished.load(); }
+ /** Bit i is set when the cache file of font i was written. */
+ unsigned writtenMask() const { return written.load(); }
+ /** Stops the task (between two fonts) and frees it; the app calls it before the I/O task stops. */
+ void stop();
+
+private:
+ static void taskMain(void* context);
+ void run();
+
+ IoTask* io = nullptr;
+ char rootPath[160] = "";
+ char ttfPath[260] = "";
+ unsigned wanted = 0;
+ TaskHandle_t task = nullptr;
+ std::atomic finished = false;
+ std::atomic stopRequested = false;
+ std::atomic written = 0;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LabelStore.cpp b/Apps/Maps/main/Source/app/LabelStore.cpp
new file mode 100644
index 0000000..5cadb8d
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LabelStore.cpp
@@ -0,0 +1,76 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "LabelStore.h"
+
+#include "../platform/Platform.h"
+
+#include
+
+namespace maps {
+
+bool LabelStore::begin() {
+ mutex = xSemaphoreCreateMutex();
+ return mutex != nullptr;
+}
+
+void LabelStore::end() {
+ for (int i = 0; i < entryCount; i++) {
+ largeFree(entries[i].bytes);
+ }
+ entryCount = 0;
+ bytesUsed = 0;
+ if (mutex != nullptr) {
+ vSemaphoreDelete(mutex);
+ mutex = nullptr;
+ }
+}
+
+int LabelStore::find(const TileKey& tile) const {
+ for (int i = 0; i < entryCount; i++) {
+ if (entries[i].tile == tile) {
+ return i;
+ }
+ }
+ return -1;
+}
+
+void LabelStore::evict(int index) {
+ bytesUsed -= entries[index].size;
+ largeFree(entries[index].bytes);
+ entries[index] = entries[--entryCount];
+}
+
+void LabelStore::put(const TileKey& source, const uint8_t* section, size_t size, int count) {
+ if (mutex == nullptr || size == 0 || size > MAX_BYTES / 4) {
+ return;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const int existing = find(source);
+ if (existing >= 0 && entries[existing].size == size) {
+ // The same tile drawn again, for another zoom level or after the raster cache dropped it
+ entries[existing].lastUse = ++useCounter;
+ xSemaphoreGive(mutex);
+ return;
+ }
+ if (existing >= 0) {
+ evict(existing);
+ }
+ while (entryCount > 0 && (entryCount >= MAX_ENTRIES || bytesUsed + size > MAX_BYTES)) {
+ int oldest = 0;
+ for (int i = 1; i < entryCount; i++) {
+ if (entries[i].lastUse < entries[oldest].lastUse) {
+ oldest = i;
+ }
+ }
+ evict(oldest);
+ }
+ auto* copy = static_cast(largeAlloc(size));
+ if (copy != nullptr) {
+ memcpy(copy, section, size);
+ entries[entryCount++] = { source, copy, size, count, ++useCounter };
+ bytesUsed += size;
+ changes++;
+ }
+ xSemaphoreGive(mutex);
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LabelStore.h b/Apps/Maps/main/Source/app/LabelStore.h
new file mode 100644
index 0000000..1021a47
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LabelStore.h
@@ -0,0 +1,70 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "../tiles/TileMath.h"
+
+#include
+#include
+
+#include
+#include
+#include
+
+namespace maps {
+
+/**
+ * The label sections of the source tiles drawn lately, copied out by the render lanes as they draw a prepared tile
+ * (TileRenderer's label hook) and read by the map loop to place labels. Entries live in PSRAM, at most
+ * MAX_BYTES together; the least recently used goes first. One z14 entry serves the z15-z19 tiles drawn from it.
+ */
+class LabelStore {
+public:
+ static constexpr int MAX_ENTRIES = 48;
+ static constexpr size_t MAX_BYTES = 384 * 1024;
+
+ bool begin();
+ void end();
+ /** Called on a render lane: keeps a copy of a tile's label section, replacing an older one. */
+ void put(const TileKey& source, const uint8_t* section, size_t size, int count);
+ /**
+ * Calls visit with the section of a source tile while holding the store's lock; false when it is not stored. The
+ * section must not be used after visit returns.
+ */
+ template
+ bool with(const TileKey& source, Visit&& visit) {
+ if (mutex == nullptr) {
+ return false;
+ }
+ xSemaphoreTake(mutex, portMAX_DELAY);
+ const int index = find(source);
+ if (index >= 0) {
+ entries[index].lastUse = ++useCounter;
+ visit(entries[index].bytes, entries[index].size, entries[index].count);
+ }
+ xSemaphoreGive(mutex);
+ return index >= 0;
+ }
+ /** Changes whenever a section was added, so the map knows when to place its labels again. */
+ uint32_t generation() const { return changes.load(); }
+
+private:
+ struct Entry {
+ TileKey tile;
+ uint8_t* bytes;
+ size_t size;
+ int count;
+ uint32_t lastUse;
+ };
+
+ int find(const TileKey& tile) const;
+ void evict(int index);
+
+ Entry entries[MAX_ENTRIES] {};
+ int entryCount = 0;
+ size_t bytesUsed = 0;
+ uint32_t useCounter = 0;
+ std::atomic changes { 0 };
+ SemaphoreHandle_t mutex = nullptr;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LocationSource.cpp b/Apps/Maps/main/Source/app/LocationSource.cpp
new file mode 100644
index 0000000..b9d6d9b
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LocationSource.cpp
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "LocationSource.h"
+
+#include "../platform/Platform.h"
+
+namespace maps {
+
+namespace {
+
+/** location_subscribe() of newer firmware, which takes what the subscriber is. Older firmware ignores the argument. */
+using SubscribeWithDescription = error_t (*)(LocationSubscription* sub, TaskEventGroup* eventGroup, const char* description);
+
+} // namespace
+
+bool LocationSource::subscribe(TaskEventGroup* eventGroup) {
+ if (subscribed) {
+ return true;
+ }
+ storage = {};
+ // Through a function type without arguments, which GCC accepts as a deliberate cast between function types
+ const auto subscribeCall = reinterpret_cast(reinterpret_cast(&location_subscribe));
+ subscribed = subscribeCall(&storage.subscription, eventGroup, "Maps") == ERROR_NONE;
+ logInfo("Location services %s, %s", location_is_enabled() ? "on" : "off", subscribed ? "subscribed" : "not subscribed");
+ return subscribed;
+}
+
+void LocationSource::unsubscribe() {
+ if (subscribed) {
+ location_unsubscribe(&storage.subscription);
+ subscribed = false;
+ }
+}
+
+bool LocationSource::isEnabled() {
+ return location_is_enabled();
+}
+
+bool LocationSource::setEnabled(bool enabled) {
+ // The subscription stays registered while the service is stopped, new fixes signal it again once it runs
+ const error_t error = location_set_enabled(enabled);
+ logInfo("Location services switched %s: %s", enabled ? "on" : "off", error == ERROR_NONE ? "done" : "failed");
+ return error == ERROR_NONE;
+}
+
+PositionRead LocationSource::read(PositionFix& fix) {
+ if (!location_is_enabled()) {
+ return PositionRead::Off;
+ }
+ Location location {};
+ if (location_get(&location) != ERROR_NONE) {
+ return PositionRead::NoFix;
+ }
+ fix.latitude = location.latitude;
+ fix.longitude = location.longitude;
+ fix.accuracy = location.horizontal_accuracy;
+ fix.course = location.course;
+ fix.speed = location.speed;
+ fix.timeMs = location.time_ms;
+ return PositionRead::Fix;
+}
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/LocationSource.h b/Apps/Maps/main/Source/app/LocationSource.h
new file mode 100644
index 0000000..23582f6
--- /dev/null
+++ b/Apps/Maps/main/Source/app/LocationSource.h
@@ -0,0 +1,45 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include "../platform/Position.h"
+
+#include
+
+namespace maps {
+
+/**
+ * The position from Tactility's location module, the app's only position source. Subscribing makes the
+ * location service run its providers (GPS, IP lookup) while the map is open, and signals a bit of the app's
+ * event group for each new fix. Location settings belong to the user: only the menu's Location switch turns the
+ * service on or off, and the precision is never changed.
+ */
+class LocationSource final : public PositionSource {
+public:
+ ~LocationSource() override { unsubscribe(); }
+
+ bool subscribe(TaskEventGroup* eventGroup);
+ void unsubscribe();
+ /** The event group bit that is signalled for a new fix, 0 when not subscribed. */
+ uint32_t bit() const { return subscribed ? storage.subscription.bit : 0; }
+
+ PositionRead read(PositionFix& fix) override;
+
+ static bool isEnabled();
+ /** Starts or stops Tactility's location service and stores the choice in its settings. */
+ static bool setEnabled(bool enabled);
+
+private:
+ /**
+ * Firmware from November 2026 on keeps a 25-byte description in the subscription's internal part, so its
+ * subscription is larger than the one of the SDK this app is built with. The service owns those internal fields
+ * and the app only reads bit (the first field in both), so room for either layout keeps both firmwares working.
+ */
+ union Storage {
+ LocationSubscription subscription;
+ uint8_t bytes[64];
+ };
+ Storage storage {};
+ bool subscribed = false;
+};
+
+} // namespace maps
diff --git a/Apps/Maps/main/Source/app/MapApp.cpp b/Apps/Maps/main/Source/app/MapApp.cpp
new file mode 100644
index 0000000..8928402
--- /dev/null
+++ b/Apps/Maps/main/Source/app/MapApp.cpp
@@ -0,0 +1,130 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#include "MapView.h"
+
+#include "../platform/Platform.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+
+#include
+#include