How I wrote a watch face for my Garmin Fēnix 8+
This year I started running and I got really into it. And as it happens with every hobby there are some purchases involved. I invested in a new watch - Garmin Fēnix 8+. I'm a bit of a geeky person so I started playing with it. I explored all the options, apps and a couple of watch faces. I also checked how much of an effort it is to create my own thing for it and it appears that it's not a big deal. This article is covering my process with some code samples and a list of the tools that I used.
If you are not up for reading here's a short podcast that goes over what's in this article:
Kago (watch face)
It's always a challenge to come up with a name for an OS project. I decided to name it Kago. It's the first letters of my kids' names. The final "product" looks like this:
There were a couple of criteria while doing it:
- I wanted to have high contrast and big time numbers. At the end that's a watch.
- I wanted to cover some basic sensor stats like: battery status, heart rate, current steps and daily goal, distance and burned daily calories.
- It's mainly a sport watch but I like to receive notifications from my phone. So, the number of unread messages.
- The date including the day of the week
- Something different every day - this I solved by rendering a stoic wisdom quote
- Something different every season - I found nice tree illustrations for every month.
The tools
First of all, I should say that the whole developer experience was kind of nice. I didn't hit a non-working tools. There is a good "Getting started" guide here.
The bare minimum is to install the Garmin Connect IQ SDK and the Moneky C VSCode extension. Since I'm using VSCode anyway it was quite easy to get something on the screen. The simulator is pretty good in a sense that we can emulate a lot of things. From changing the time to starting an activity and draining the battery of the device.
Moving forward I used two other tools that I think are necessary:
- A TTF to FNT converter - of course there needs to be a custom font. To do that we need to transform the TTF to FNT file. There is a free online tool here.
- OpenMTP - unfortunatelly because I'm living in Eastern Europe I couldn't install my own watch face from the Connect IQ store. Even though I was able to publish it there I can't put it on my own device. So, I had to install it manually. OpenMTP is a nice tool that acts as a file explorer of my watch when it's connected to my Mac.
Let's now explore how I did the various parts.
How to start
I can't say it better than the article here but shortly - you have to use the generator that comes with the Monkey C VSCode extension. It will create all the necessary files and configuration. Then it's just Ctrl+F5 (Start Without Debugging). You'll see some things happening in the terminal and the simulator will boot up.
Using a custom font
When you transform the TTF file to FNT you end up with two files - .fnt
and .png
. The FNT format is actually an image + description of where is every letter. So, you need to copy both files into the directory of the project. I guess it's a good practice to hold those two files under resources/fonts
. Once the files are there make sure that there is a proper reference to the .png
file inside the .fnt
file. For example:
// Montserrat_12.fnt
page id=0 file="Montserrat_12.png"
(Montserrat
is the name of my font)
Once the font is inside the project we have to define it as a resource. This happens in a XML file - resources/fonts/fonts.xml
:
<resources>
<font id="MontserratFont12" filename="./Montserrat_12.fnt" />
</resources>
The id
is important because later in the code we are referencing it using that id
. For example:
import Toybox.WatchUi;
import Toybox.Graphics;
// ...
var text = new WatchUi.TextArea({
:text => "Sample text",
:font => WatchUi.loadResource(Rez.Fonts.MontserratFont22),
:width => 200,
:height => 200,
:justification => Graphics.TEXT_JUSTIFY_CENTER
});
text.draw(dc);
Using images
Similarly to the fonts we have to place the images (I used .png
files) under the directory of the project. Then we define a resources.
// drawables/drawables.xml
<drawables>
<bitmap id="TreeJanuary" filename="tree1.png" />
</drawables>
Then later in the .mc
file:
var bitmap = Rez.Drawables.TreeJanuary;
var image = new WatchUi.Bitmap({
:rezId => bitmap
});
image.setLocation(200, 100);
image.draw(dc);
Colors
The colors are usually defined into the settings/properties.xml
file. Like so:
<properties>
<property id="SensorStatsColor" type="number">0x999999</property>
</properties>
In the code we can reference them with Application.Properties.getValue("<name of the property>")
. For example:
var text = new WatchUi.TextArea({
:text => "Sample text",
:color => Application.Properties.getValue("SensorStatsColor") as Number
});
Getting current time and date
We are doing a watch face so this is the main thing to render right. In fact when the Monkey C VSCode extension generates a project it shows the time as a default logic. It looks like this:
import Toybox.System;
// ...
var timeFormat = "$1$:$2{post}quot;;
var clockTime = System.getClockTime();
var hours = clockTime.hour;
if (!System.getDeviceSettings().is24Hour) {
if (hours > 12) {
hours = hours - 12;
}
} else {
if (Application.Properties.getValue("UseMilitaryFormat")) {
timeFormat = "$1$2{post}quot;;
}
}
var timeString = Lang.format(timeFormat, [hours.format("%02d"), clockTime.min.format("%02d")]);
Notice how the timeString
string is prepared based on a system setting System.getDeviceSettings().is24Hour
and also a watch face property UseMilitaryFormat
.
The date is available via the Gregorian
class:
using Toybox.Time.Gregorian;
// ...
var today = Gregorian.info(Time.now(), Time.FORMAT_MEDIUM);
var dayOfWeek = Gregorian.info(Time.now(), Time.FORMAT_SHORT).day_of_week;
var dateString = Lang.format("$1$\n$2$\n$3{post}quot;, [
self._getDayOfWeek(dayOfWeek),
today.day.format("%02d"),
today.month
]);
I created a simple utility function so I can produce a shorter version of the week:
function _getDayOfWeek(dayOfWeek as Number) as String {
switch (dayOfWeek) {
case Time.Gregorian.DAY_MONDAY:
return "Mon";
case Time.Gregorian.DAY_TUESDAY:
return "Tue";
case Time.Gregorian.DAY_WEDNESDAY:
return "Wed";
case Time.Gregorian.DAY_THURSDAY:
return "Thu";
case Time.Gregorian.DAY_FRIDAY:
return "Fri";
case Time.Gregorian.DAY_SATURDAY:
return "Sat";
case Time.Gregorian.DAY_SUNDAY:
return "Sun";
}
return "___";
}
Reading from the watch's sensors
It depends of what we want but most of the stuff are available through the ActivityMonitor
class. For example:
import Toybox.ActivityMonitor;
function getSteps() as Number? {
return ActivityMonitor.getInfo().steps;
}
function getDistanceMeters() as Float? {
var distanceCm = ActivityMonitor.getInfo().distance;
return distanceCm != null ? distanceCm.toFloat() / 100 : null;
}
function getBattery() as Number {
return System.getSystemStats().battery.toNumber();
}
Not everything is there though. The current hear rate for example is accessible via the Activity
class.
var heartRate = 0;
var heartRateText = "...";
var actInfo = Activity.getActivityInfo();
if (actInfo != null) {
heartRate = actInfo.currentHeartRate;
if (heartRate != 0 && heartRate != null) {
heartRateText = heartRate.format("%d");
}
}
There is a nice documentation here. The tricky thing is that it's really just a reference API docs pages. So, it is not how-to guide and we have to dig deeper to find what we want.
Drawing on the screen
There are a couple of elements on my watch face which are dynamic (not images) and are generated on the fly using the SDK.
The pinky elements are produced by the graphic context drawing methods. For example to draw all the little circles in equal chunks I used a loop, a little basic math and drawCircle
.
dc.setPenWidth(1);
var radius = (dc.getWidth() / 2) - 4;
var angles = [0, 15, 30, 45, 60, 75, 90, 105, 120, 135, 150, 165, 180, 195, 210, 225, 240, 300, 315, 330, 345];
for (var i = 0; i < angles.size(); i++) {
var angle = angles[i];
var theta = Math.toRadians(angle);
var x2 = (dc.getWidth()/2) + (radius - 4) * Math.cos(theta);
var y2 = (dc.getHeight()/2) + (radius - 4) * Math.sin(theta);
dc.drawCircle(x2, y2, 2);
}
I wanted to have also a daytime progress bar. In the context of that watch interface, that's a circle which is filling up while the day progresses. So, I had to draw a white circle (easy) and an arc whose length depends on the current hour. That was quite interesting to do:
// white circle
dc.setColor(DayArcColorCharged, BackgroundColor);
dc.drawArc(
screenWidth / 2, // x
screenHeight / 2, // y
(screenWidth / 2) - 2, // radius
Graphics.ARC_COUNTER_CLOCKWISE,
0, // start angle
360 // end angle
);
// pink arc
var clockTime = System.getClockTime();
var percentageHour = (((clockTime.hour * 60.0) + clockTime.min) / (24.0 * 60.0)) * 100;
var angleHour = 360 - (percentageHour / 100 * 360);
dc.setColor(DayArcColor, BackgroundColor);
dc.drawArc(
screenWidth / 2, // x
screenHeight / 2, // y
(screenWidth / 2) - 2, // radius
Graphics.ARC_COUNTER_CLOCKWISE,
90, // start angle
angleHour + 90 // end angle
);
Final words
Overall it was quite a lot of fun for me to create my own watch face. It reminded me a bit of the good old days when I had to draw stuff on the screen using some sort of graphics API.
The code is all open and you can find it here. If you are interested in trying the actual watch face you can grab it here.