Waypoint - Configuration from the Companion App
Some settings are miserable to enter with four buttons. A latitude, a name, a threshold: the user has a phone in their pocket with a keyboard on it, and the companion app can ask them once. Waypoint is a small utility app built around that idea. It navigates back to a coordinate the user sets from their phone, and it can overwrite that coordinate with wherever the user is standing.
The full contract behind this tutorial is App Configuration Fields. This page is the worked example.
What You’ll Learn
How to declare configuration fields in
app-manifest.json- one of each supported typeHow the companion app turns them into a file on the watch, and where it lands
How to read those values with
SDK::AppConfig, and why every read has a defaultHow to tell “the user chose this” from “this is our fallback”
How to write a value back from the watch without ever corrupting the file
How CI stops your app’s idea of a field from drifting away from
app-manifest.json
How configuration reaches the watch
app-manifest.json Companion app 2:/Apps/Waypoint/
+----------------------+ +-------------------+
| "configFile" | 1. reads the package | Waypoint.uapp |
| "configFields": [ |------------------------------> | app_config.json | <--+
| waypointName | 2. asks the user +-------------------+ |
| targetLatitude | 3. validates the input | |
| targetLongitude | 4. writes the values file ---------------+ |
| arrivalRadiusM | | |
| vibrateOnArrival | 5. read at launch |
| ] | v |
+----------------------+ SDK::AppConfig |
(never leaves the phone) | |
6. "save my position" | |
+--------------+
app-manifest.json itself never reaches the watch. Only the values file does.
Getting Started
Prerequisites
Same as HelloWorld: UNA_SDK set, the ST ARM GCC
toolchain on PATH, CMake 3.21+, and Python 3. See the toolchain setup.
Waypoint declares "requiredHardware": ["GPS"], so on hardware it needs a watch with GPS.
Building
cd $UNA_SDK/Docs/Tutorials/Waypoint
mkdir build && cd build
cmake -G "Unix Makefiles" ../Software/Apps/Waypoint-CMake
make
On Windows, generate with Ninja instead - cmake -G Ninja ../Software/Apps/Waypoint-CMake.
The .uapp is produced in the build directory; the final step that copies it into Output/
uses a wildcard that cmake -E copy does not expand on Windows, so copy it across yourself
if the build stops there.
Trying it without a phone
The simulator’s app sandbox is a directory on your PC - the GUI logs its path at startup
(“Path to files created by app”). Drop a file called app_config.json in there:
{
"schema": 1,
"values": {
"waypointName": "Trailhead",
"targetLatitude": 51.5072,
"targetLongitude": -0.1276,
"arrivalRadiusM": 40
}
}
Restart the simulator and the app reads it exactly as it would on the watch. Delete the file and every field falls back to its declared default - which is worth trying, because that is the path most apps get wrong.
Declaring the fields
Output/app-manifest.json carries two extra keys beyond the usual package metadata (which
now starts with "manifest_version": 1 - required in every manifest, configuration fields or
not). configFile names the file the companion app will write, and configFields lists what
to ask for. Waypoint declares five fields covering
all four supported types — two of them floats, for the coordinate pair. Abridged (see
Output/app-manifest.json for the full declaration, including targetLongitude):
"configFile": "app_config.json",
"configFields": [
{
"id": "waypointName",
"type": "string",
"label": "Waypoint name",
"description": "Shown on the watch while you navigate. Letters, digits and spaces only.",
"default": "Waypoint",
"minLength": 1,
"maxLength": 16,
"pattern": "[A-Za-z0-9 ]+",
"validationMessage": "Up to 16 letters, digits and spaces."
},
{
"id": "targetLatitude",
"type": "float",
"label": "Target latitude",
"description": "Latitude of the waypoint in decimal degrees. North is positive.",
"default": 51.5072,
"min": -90.0,
"max": 90.0,
"unit": "deg",
"required": true
},
{
"id": "arrivalRadiusM",
"type": "int",
"label": "Arrival radius",
"description": "How close you need to get before the watch counts you as arrived.",
"default": 25,
"min": 5,
"max": 500,
"unit": "m"
},
{
"id": "vibrateOnArrival",
"type": "bool",
"label": "Vibrate on arrival",
"description": "Buzz the watch once when you come within the arrival radius.",
"default": true
}
]
Points worth copying:
minandmaxare mandatory onintandfloat. They are what the companion app validates against and what the app clamps to, and an unbounded number is usually a field nobody has finished thinking about.maxLengthis mandatory onstring, in UTF-8 bytes, because the app sizes a buffer from it.defaultis mandatory everywhere. It pre-fills the phone’s input and it is what the app falls back to.required: trueon the two coordinates means the install cannot finish until the user supplies them - a waypoint app with no waypoint is not much use.patternis a deliberately small regex dialect and always matches the whole value, so no^or$. See the dialect rules.unitis decoration the phone renders beside the input. It is never stored in the value, so do not put units inlabeltoo.
The field table the app carries
app-manifest.json stays on the phone, so the app repeats the contract in
Software/Libs/Sources/AppConfigFields.cpp:
const AppConfig::Field kFields[] = {
AppConfig::stringField("waypointName", "Waypoint", 1, 16),
AppConfig::floatField("targetLatitude", 51.5072f, -90.0f, 90.0f),
AppConfig::floatField("targetLongitude", -0.1276f, -180.0f, 180.0f),
AppConfig::intField("arrivalRadiusM", 25, 5, 500),
AppConfig::boolField("vibrateOnArrival", true),
};
This is duplication on purpose: it is what lets the app clamp a value it should never have received - from a hand-edited file, a downgrade, or a buggy companion build. Duplication that can drift is a bug waiting to happen, so CI compares the two:
cd $UNA_SDK/Docs/Tutorials/Waypoint
python $UNA_SDK/Utilities/Scripts/app_packer/validate_app_config.py \
--check Output/app-manifest.json \
--check-bounds Software/Libs/Sources/AppConfigFields.cpp \
--check-bounds Software/Libs/Header/AppConfigFields.hpp
Change 500 to 400 in either place and the build fails with the two values side by side.
Run it yourself before opening a pull request - it also checks the whole declaration:
unique ids, default satisfying its own constraints, the regex dialect, and the worst-case
size of the values file.
Reading the values
One object, created once, at the top of run():
void Service::run()
{
mConfig.reset(new SDK::AppConfig(mKernel, WaypointConfig::kFileName,
WaypointConfig::kFields,
WaypointConfig::kFieldCount));
loadConfiguration();
...
}
void Service::loadConfiguration()
{
mConfig->getString("waypointName", mWaypointName, sizeof(mWaypointName));
mTargetLatitude = mConfig->getFloat("targetLatitude");
mTargetLongitude = mConfig->getFloat("targetLongitude");
mArrivalRadiusM = mConfig->getInt("arrivalRadiusM");
mVibrateOnArrival = mConfig->getBool("vibrateOnArrival");
mTargetIsConfigured = mConfig->has("targetLatitude") &&
mConfig->has("targetLongitude");
}
Not in the constructor, deliberately. SDK::AppConfig logs when a file is unusable, and in
the simulator the service is constructed before TouchGFX’s HAL exists — which is what the
logger writes through, so logging that early segfaults the simulator before your app draws
anything. Do the reading on the service thread, in run().
Note what the getters do not need: no error handling, no “if the file exists” branch, no range checks. Every getter returns either the stored value, clamped to the bounds you declared, or the field’s default. That is the whole point - a configuration problem can never stop the app from starting.
has() is the one thing worth thinking about. It answers “did this come from the user?”,
which is different from “is it non-zero”. Waypoint uses it to avoid navigating confidently
towards its own placeholder coordinate:
if (!nav.targetIsConfigured) {
setStatus("Set a target");
}
Values are read once, at launch. A change the user makes on their phone applies the next time they open the app. The companion app tells them so; your app does not need to watch the file.
What happens when things go wrong
Every one of these degrades to defaults rather than to a crash or a failed launch:
Condition |
Result |
|---|---|
No values file at all |
Every field uses its default; |
Not valid JSON, or bigger than 8 KB |
Every field uses its default |
|
Every field uses its default |
One value has the wrong JSON type, or is |
That field alone uses its default |
One number is outside |
That field alone is clamped |
One string is longer than |
That field alone is truncated, on a character boundary |
Writing a value back from the watch
Configuration is not one-way. Pressing R1 stores the current position as the new target, so the user can save a spot without typing coordinates at all:
void Service::saveTargetHere()
{
if (!mHasFix) {
SDK::send_msg<CustomMessage::TargetSaved>(
mKernel, CustomMessage::SaveOutcome::NoFix, ...);
return;
}
mConfig->setFloat("targetLatitude", mLatitude);
mConfig->setFloat("targetLongitude", mLongitude);
if (!mConfig->save()) {
// The setters already updated the in-memory values, so re-reading now
// would adopt coordinates that never reached the file. Keep serving the
// previous target and say so.
SDK::send_msg<CustomMessage::TargetSaved>(
mKernel, CustomMessage::SaveOutcome::WriteFailed, ...);
return;
}
loadConfiguration(); // re-read, so what is in play is what is on disk
...
}
The three outcomes are distinct on purpose. SaveOutcome (Saved, NoFix, WriteFailed)
travels to the GUI instead of a bare bool, because “there was nothing to save” and “the
write failed” need different words on the status line - and only one of them is a fault.
Three things save() does that are easy to get wrong by hand:
It never truncates the live file. The new document goes to
app_config.json.tmp, which is then renamed over the original. A reset mid-write leaves the previous target intact, and the next launch finishes the rename.It leaves values it did not change exactly as they were - byte for byte. Re-serialising
51.5072as a float would write51.5071983, and the user’s number would drift a little every time the app saved.It preserves keys it does not recognise, so installing an older build of your app does not silently discard a newer version’s settings. The single exception is a key that appears twice: only the first copy is kept, because the watch reads the first and the phone would read the last, and a file that makes them disagree is worse than a tidy one.
The file has two writers now - the phone and the watch - so the rule is last writer wins, and the companion app re-reads the file before it shows its edit screen. The only way to lose a change is to edit on both at the same moment.
The screen
MainView renders four wildcard text areas: name, distance, bearing and a status line. The
wildcard typographies (TMP_SEMIBOLD_40 and friends) already carry the full ASCII set, so
arbitrary runtime text needs no font regeneration:
std::snprintf(text, sizeof(text), "%d m", static_cast<int>(nav.distanceM));
touchgfx::Unicode::strncpy(distanceTextBuffer, text, DISTANCETEXT_SIZE);
distanceText.invalidate();
setupScreen() writes every wildcard before the screen is shown. A wildcard that is
never set keeps whatever placeholder the text database holds, and a design-time placeholder
that ships to users looks exactly like real data.
Buttons: R1 saves the current position as the target, R2 exits.
Message flow
The service owns the configuration; the GUI only asks and displays.
GPS fix R1 pressed
| |
v v
Service GUI
distance + bearing SAVE_TARGET_HERE ->
| |
| NAV_UPDATE (NavState: name, distance, bearing, flags) |
+----------------------------------------------------------> |
|
<- setFloat + save() + re-read, then TARGET_SAVED ---------- +
NavState is a plain struct rather than message fields, because a MessageBase is
pool-allocated and non-copyable - the GUI could not keep a copy of the last update to redraw
from.
Next Steps
App Configuration Fields - the full contract, including the companion-app specification and every limit
Files - settings the user edits on the watch, which is the other half of this story
Sensors - the rest of the sensor layer this app only dips into
Troubleshooting
Every value is the default. The app could not use the file. The log names the reason for
a file that exists but cannot be used — “not valid JSON”, “has no schema”, “over the
8192-byte limit” — and a schema other than 1 lands here by design. A file that simply
isn’t there is not logged, so check isLoaded() if you need to tell “missing” from
“unusable”.
One value is the default and the others are fine. That key is the wrong JSON type for its
declared field, or null, or a number with a fractional part in an int field. The companion
app cannot produce any of those; a hand edit can.
The value is not what the phone shows. It was outside min..max and got clamped, or the
string was longer than maxLength and got truncated. Neither is logged — they happen silently
inside the getters — so compare the file against your field table if a value looks capped.
CI keeps the two in step, but a hand-edited file can hold anything.
A change on the phone did nothing. Values are read at launch. Close the app and reopen it.
A confident distance appears before there is a fix. GpsLocation::isDataValid() only
says the sample is a well-formed GPS record; the fix itself is isCoordinatesValid(). Check
both, or a fix-less sample reads as latitude 0, longitude 0 and you will navigate to the Gulf
of Guinea.
The watch keeps alerting after you leave the app. The kernel does not stop a service when
its GUI closes, so a service that does not end itself keeps running - holding its sensor
subscriptions open and still firing alerts. Waypoint returns from run() on
COMMAND_APP_NOTIF_GUI_STOP; see the Stopwatch
notes for the case where staying alive is the right choice.
The simulator segfaults before drawing anything. Something logged before TouchGFX’s HAL
existed. The service is constructed early, so anything in a constructor that reaches
LOG_* — directly or through an SDK class — crashes there while working fine on the watch.
Move it into run().
The simulator fails to link with undefined reference to SDK symbols. Unlike the CMake
target, the simulator lists its sources by hand in simulator/gcc/Makefile (and
simulator/msvs/Application.vcxproj). Add what you use — this app needed AppConfig.cpp,
SensorConnection.cpp, coreJSON and the sensor-layer simulator. An app that uses the sensor
layer also needs its own simulator/ConfigurationSimulator.hpp saying which sensors to
simulate.
CI fails with “field table … but app-manifest.json”. The two declarations disagree. The error names the field and both values; fix whichever is wrong.
“app-manifest.json declares configFile X, but the checked sources only name Y”. The app opens a
different filename than it declared. Update kFileName or configFile so they match.