So I now have a working
widget on the watch. Just not the one I wanted.
There are a number of ways I can go from here, but it amounts to understanding how the app I have works (and by extension the platform), and then to figure out how to build the app I want.
On one hand, I like working with "platforms", inasmuch as all the boilerplate code is done for you, and you are then just asked to provide the missing pieces. On the other hand, the flow of control is out of your hands, and if something "doesn't happen", it's not at all clear why not: did I not configure some XML correctly? did I not place my code in the right place? did it not implement some interface? was it called and then did it cause an error? And very often it's hard to investigate and debug.
So my first step is going to be to try and add in some debugging statements to the current code to see if the simulator will spit out information about what it's doing and in what order. I'm not sure this will help me any more than reading the documentation, but at least it will be there as I try to put my own logic in place.
The
Hello, Monkey C! page suggests that it is possible to print values to "the debug console" by using
System.println. I copy their code into
getApp() in
metrolink-watchApp.mc:
function getApp() as metrolink_watchApp {
System.println( "get app" );
return Application.getApp() as metrolink_watchApp;
}
This gives me an error on build:
/Users/gareth/Projects/IgnoranceBlog/metrolink-watch/source/metrolink-watchApp.mc:27,4: Cannot find symbol ':System' on type 'self'
It turns out that
System needs to be explicitly referenced at the top of the file:
import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
using Toybox.System;
(it's interesting to me that this uses
using where all the lines already there use
import - what's the difference?).
But even fixing this does not produce any output. The simulator appears, and the window appears, and the monkey appears, but no output appears.
I put a breakpoint on this line and try
Start Debugging. The breakpoint is not hit, and the output does not appear. What else could be going wrong?
My experience with Visual Studio in the past - and particularly with languages the compile to binary form - is that they use two versions - release and debug - and (obviously) you can't debug a release build. I've seen some references to this here, including when "building for device", so I think that may be related. It's also worth noting that the output I
am getting includes the message "building for device", so it's not clear that the simulator version is updating at all. Looking in the
bin/ and
vivoactive/ directories however, the version which is being updated is
bin/metrolinkwatch.prg and its corresponding
.debug.xml. There is also a
test_ version which has not been updated, but that is
test and not
debug, so I am going to ignore that for now.
Let's try something else. The configuration has a property
stopAtLaunch which by default is
false. I am going to set that to
true and see what happens. Wow! That works. And puts the breakpoint at line 8 in my code, which is the function
initialize. So maybe my biggest problem was just guessing incorrectly that
getApp() was the root of the program. In which case, I wonder what it does and what it's there for.
OK, I'm going to step through this, and then try adding some more
println statements and see what happens.
import Toybox.Application;
import Toybox.Lang;
import Toybox.WatchUi;
using Toybox.System;
class metrolink_watchApp extends Application.AppBase {
function initialize() {
System.println("initialize");
AppBase.initialize();
}
// onStart() is called on application start up
function onStart(state as Dictionary?) as Void {
System.println("start");
}
// onStop() is called when your application is exiting
function onStop(state as Dictionary?) as Void {
System.println("stop");
}
// Return the initial view of your application here
function getInitialView() as [Views] or [Views, InputDelegates] {
System.println("get view");
return [ new metrolink_watchView() ];
}
}
function getApp() as metrolink_watchApp {
System.println( "get app" );
return Application.getApp() as metrolink_watchApp;
}
So now it is behaving in the way I'd expected. It really just was as simple as that it wasn't going through the code that I had assumed was the top level. And now I see messages in the
DEBUG CONSOLE corresponding to my
println statements:
initialize
start
get view
I can't figure out how to tell the simulator that I have finished with my widget, so I can't get the
stop message to come out.
Turning to the
View, I see there are methods in there for
initialize,
layout,
show,
update and
hide, which I'm assuming are in the order you would expect and (logically) all come after
getView.
class metrolink_watchView extends WatchUi.View {
function initialize() {
System.println("init view");
View.initialize();
}
// Load your resources here
function onLayout(dc as Dc) as Void {
System.println("layout");
setLayout(Rez.Layouts.MainLayout(dc));
}
// the state of this View and prepare it to be shown. This includes
// loading resources into memory.
function onShow() as Void {
System.println("show");
}
// Update the view
function onUpdate(dc as Dc) as Void {
System.println("update");
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
}
// state of this View here. This includes freeing resources from
// memory.
function onHide() as Void {
System.println("hide");
}
}
Having added these diagnostic statements, the following output appears:
initialize
start
get view
init view
layout
show
(2)update
Interestingly,
onUpdate is called twice in rapid succession.
This is all checked in and tagged as
METROLINK_MC_DEBUGGING.
As an aside, by selecting the
Run/Debug perspective (the fourth item on the left hand navigation bar in VSCode), I see at the top a
RUN AND DEBUG dropdown which enables me to select the launch configuration to be used. I don't know how it chooses the one it chooses, but it
does enable me to choose the one I want to choose. Excellent. Now, can I remember that for when it comes up in future?
My Big Plan
In the short term at least, I basically want to hardcode all the data about routes I am interested in. But I want to hardcode it
as if it were coming from some configuration file.
If I understand the documentation correctly, the "initial view" is there just to get you started. Because of navigation constraints, it cannot be "scrolled" but it can be "tapped" to launch another view, or it can offer a menu of choices (which launch other views) and it can display information. I think for now I would like it to display as much "next tram" information for your current query as will fit on the screen, with the option to somehow (swipe left/right?) move to a view that will allow you to select different routes. Whichever one you leave it on will then become the default. Tapping should keep the same route but allow you to scroll through all the data.
Breaking this down, it seems to me that I have six separate tasks right now:
- Provide the hardcoded configuration of routes;
- Get the data from the PHP server we wrote earlier;
- Figure out how to define a layout;
- Figure out how to populate the layout;
- Figure out how to have a dynamic layout, or change the layout, or something so that the layout fits the data;
- Wire everything up.
Obviously the last one must be done last, and the three layout tasks flow between each other, but whether to tackle the configuration, data or layout first is just a choice. I generally prefer to work in the direction of flow of data (unless I feel that something else is driving my choices), so I am going to proceed in exactly the order given here.
Encoding the Routes
One of the issues I have here is that I'm not entirely sure what code is supposed to go where. I've read a lot of the documentation, so I think I know, but I always feel that this is something that only really comes with experience. I also (obviously) feel a little hamstrung by not understanding either the data model or the file layout model. But then, of course, that is why I am doing this: to find those things out.
So, for now, I'm going to say that I am going to put the configuration of the data into
initialize(), and the request for the data into
update(). One of my concerns is that the call to request the data is asynchronous, and it is common in languages and frameworks for the callback to be somewhere where the UI cannot be updated, so it may be that
update() is not really the right place to request the data. We will see what happens.
So, encoding the routes is very easy (assuming I do it correctly). For now, all I want is a list of
from station codes and a list of
to station codes. I think I also want each route to have a name as a handle to refer to it later (although this may not be the case). And I want to put them all in a list.
class metrolink_watchApp extends Application.AppBase {
var routes as Array<Route>;
function initialize() {
System.println("initialize");
AppBase.initialize();
routes = configureRoutes();
}
...
function configureRoutes() as Array<Route> {
var altiRoute = new Route("ALTI", ["MKT", "PCG"], ["ALT"]);
var traffRoute = new Route("TRC", ["TRC"], []);
return [ altiRoute, traffRoute ];
}
class Route {
var name as String;
var from as Array<String>;
var to as Array<String>;
public function initialize(name as String, from as Array<String>, to as Array<String>) {
self.name = name;
self.from = from;
self.to = to;
}
}
This is checked in as
METROLINK_MC_ROUTES.
I learnt a few things here. First of all,
declaring a class is easy, using the
class keyword. Members are declared using the
var keyword; types are optional but are specified afterwards using
as type notation. The constructor is a function called
initialize and it's very important to spell this correctly, otherwise you will keep getting errors about mismatched argument lists. Lists are called
Arrays, and can be typed using alligator notation (e.g.
Array<String>).
So far, so good.
Making the WebRequest
I'm going to make the web request (for now at least) from the
update() function in the view. I said earlier that we could do these things in any order and then wire them up at the end. So, for now, I am just going to make
a web request and ignore the routes that we just configured.
Making a web request is described in the manual and I'm basically going to just try and copy that.
import Toybox.Graphics;
import Toybox.WatchUi;
import Toybox.Communications;
class metrolink_watchView extends WatchUi.View {
// Update the view
function onUpdate(dc as Dc) as Void {
System.println("update");
var params = { "from[]" => "FIR" };
var options = {
:method => Communications.HTTP_REQUEST_METHOD_GET,
:headers => { "Content-Type" => Communications.REQUEST_CONTENT_TYPE_URL_ENCODED },
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_URL_ENCODED
};
var responseCallback = method(:onReceive);
Communications.makeWebRequest("https://gmmapowell.com/metrolink-data.php", params, options, responseCallback);
// Call the parent onUpdate function to redraw the layout
View.onUpdate(dc);
}
function onReceive(responseCode, data) {
if (responseCode == 200) {
System.println("Request Successful");
} else {
System.println("Request failed, code: " + responseCode);
};
};
// Called when this View is removed from the screen. Save the
// state of this View here. This includes freeing resources from
// memory.
When I try and compile this, I get the message:
ERROR: vivoactive3: /Users/gareth/Projects/IgnoranceBlog/metrolink-watch/source/metrolink-watchView.mc:29,8: Permission 'Communications' required for '$.Toybox.Communications'.
Apparently, I need to provide some specific permission. I saw something about that in the
manifest.xml, and
going back to that, I can see that there is indeed a
Permissions section with a checkbox for
Communications. Let's select that.
There are a number of other problems with this code as well, which is surprising for something that I basically copied from the documentation. First off, there are semicolons after the closing braces at the end of
onReceive. I have no idea what those are doing there, but I am going to assume there was a version of the compiler that either allowed them or required them and the documentation has not been updated.
More subtly, the function
onReceive apparently needs to be typed. The error that appears is truly horrifying and suggests an awful type is required, but it is just very accepting and says that the method must take a
Number as its first argument, return
Void and then has a range of options for its second argument - so I chose
String.
import Toybox.Lang;
...
function onReceive(responseCode as Number, data as String) as Void {
if (responseCode == 200) {
System.println("Request Successful");
} else {
System.println("Request failed, code: " + responseCode);
}
}
The code then compiles (see tag
METROLINK_MC_WEB_REQUEST_COMPILES), but when it runs, returns a code of
-400. I don't know if this is supposed to be
400 and a minus sign has just been put on the front or if this is a special code. Anyway, looking back at the code, I know that I really want JSON back, but I have specified that I want
HTTP_RESPONSE_CONTENT_TYPE_URL_ENCODED, so that is possibly the problem. Changing that to
HTTP_RESPONSE_CONTENT_TYPE_JSON leads to a successful call, but (I remember from reading the documentation) the response is then parsed and I should be getting a dictionary back. Some quick checks confirm that I should in fact have declared the argument as a
Dictionary and then I can check that I can extract the key
Firswood - the station I requested as the station to depart from.
var options = {
:method => Communications.HTTP_REQUEST_METHOD_GET,
:headers => { "Content-Type" => Communications.REQUEST_CONTENT_TYPE_URL_ENCODED },
:responseType => Communications.HTTP_RESPONSE_CONTENT_TYPE_JSON
};
...
function onReceive(responseCode as Number, data as Dictionary) as Void {
if (responseCode == 200) {
System.println("Request Successful");
System.println(data["Firswood"]);
} else {
System.println("Request failed, code: " + responseCode);
}
}
Rejoice! And don't forget to check in (
METROLINK_MC_WEB_REQUEST_SUCCESSFUL).
The Initial View
Moving on to the view. This seems to me the hardest part, both because I'm so bad at visual things and because I'm really not sure what my options are for generating what I want. Looking at the
section on layouts in the documentation, it seems that the easiest thing to do is probably to make my layout just a
text-area and then scribble want I want into it and hope for the best. Hopefully, this at least gets me to the point where I can wire up one route, and then I can come back to the rest.
So, creating the layout and attaching it to the view does not seem so hard:
In
route.xml:
<layout id="RouteLayout">
<text-area width="fill" height="fill" />
</layout>
In the view:
function onLayout(dc as Dc) as Void {
System.println("layout");
setLayout(Rez.Layouts.RouteLayout(dc));
}
(see
METROLINK_MC_DEFINE_LAYOUT).
I could not find anything in the developer guide to explain how to bind some text to this resource; after some Googling around, I found an example which used
View.findDrawableById and confirmed that I could use that to display some simple text on the watch face.
class metrolink_watchView extends WatchUi.View {
var textArea;
...
function onLayout(dc as Dc) as Void {
System.println("layout");
setLayout(Rez.Layouts.RouteLayout(dc));
textArea = self.findDrawableById("routeInfo") as TextArea;
}
...
function onUpdate(dc as Dc) as Void {
System.println("update");
textArea.setText("hello, world");
(This is tagged as
METROLINK_MC_SHOW_TEXT_IN_AREA.)
Quickly, then, can I display multiple lines of data in this box using standard line-feed characters?
function onUpdate(dc as Dc) as Void {
System.println("update");
textArea.setText("hello, world\nthis is a test\nof multiple lines\n");
Yes, I can (see
METROLINK_MC_SHOW_MULTIPLE_LINES).
Finally, I raised the question earlier of whether or not this code would work inside the callback. Moving the code there led to a blank screen. A quick bit of research indicates that it is necessary to call
WatchUi.requestUpdate. Adding this in does indeed cause the text to appear, although it creates a loop: requesting an update calls
onUpdate which makes another request, which responds, causing a new request for an update. This is not an error, as such, but it will use an excessive amount of battery. Anyway, that is a problem to come back to, since what we will probably want to do is defer repeated updates until a later time, and I don't know how to do that yet.
if (responseCode == 200) {
System.println("Request Successful");
System.println(data["Firswood"]);
textArea.setText("hello, world\nthis is a test\nof multiple lines\n");
WatchUi.requestUpdate();
} else {
System.println("Request failed, code: " + responseCode);
}
Checked in and tagged as METROLINK_MC_SHOW_IN_RESPONSE.
Wire it all up
So, from here, it should be a simple matter of programming. I want to pass the list of configured routes from the "app" to the "view", use the first of those to make the web request, and then format the response into my text box. Should be easy.
Some of it was, some wasn't. Passing the route around was easy enough:
In the app:
function getInitialView() as [Views] or [Views, InputDelegates] {
return [ new metrolink_watchView(routes[0]) ];
}
In the view:
class metrolink_watchView extends WatchUi.View {
var route;
var textArea;
...
function initialize(route as Route) {
self.route = route;
View.initialize();
}
Passing the parameters to the back end was not so easy. The PHP code requires us to pass the parameters as
from[] and
to[] and to do so multiple times. There does not seem to be a way of handling this in Monkey C's
makeWebRequest, and I did extensive research on this. But, pushing back on the PHP end, it seems that any parameters that start
from[ will be considered identical, even if they are different after that. So I can pass the parameters as
from[0],
from[1], etc. That requires a bit of code, but I'm not scared:
function onUpdate(dc as Dc) as Void {
var params = {};
if (route.from) {
for (var i=0;i<route.from.size();i++) {
var key = "from[" + i + "]";
params[key] = route.from[i];
}
}
if (route.to) {
for (var i=0;i<route.to.size();i++) {
var key = "to[" + i + "]";
params[key] = route.to[i];
}
}
At the other end, we need to convert the
Dictionary we get back (which is parsed from the JSON) into a readable message. It seems to me that the easiest way to do this is just to list it all out vertically, with the "from" station first, then the "to" station, then a line with all the times on, and repeat. Over time, it's possible that I can find ways to improve this, but for now I'm just trying to get something to work.
Monkey C doesn't appear to have a
StringBuilder class or equivalent, but it does have a
toCharArray() method on a
String, and
StringUtil has a
charArrayToString() method; and an
Array is flexible enough to grow with the
add() and
addAll() methods. In short, we can do this:
function assembleMessage(data as Dictionary) as String {
var chars = [];
var keys = data.keys();
for (var i=0;i<keys.size();i++) {
var fromX = keys[i];
var toX = data[fromX];
assembleFrom(chars, fromX as String, toX);
}
return StringUtil.charArrayToString(chars);
}
function assembleFrom(chars as Array<Char>, from as String, to as Dictionary) {
chars.addAll(from.toCharArray());
chars.add('\n');
var dests = to.keys() as Array<String>;
for (var i=0;i<dests.size();i++) {
var toX = dests[i];
chars.addAll("=> ".toCharArray());
chars.addAll(toX.toCharArray());
chars.add('\n');
var times = to[toX];
assembleTimes(chars, times);
}
}
function assembleTimes(chars as Array<Char>, times as Array<String>) {
for (var i=0;i<times.size();i++) {
chars.add(' ');
chars.add(' ');
chars.addAll(times[i].toCharArray());
}
chars.add('\n');
}
And then it's very easy to call this from the
onReceive code:
function onReceive(responseCode as Number, data as Dictionary) as Void {
if (responseCode == 200) {
textArea.setText(assembleMessage(data));
WatchUi.requestUpdate();
} else {
System.println("Request failed, code: " + responseCode);
}
}
Too much of the text is missing for my liking, due to the circular nature of the watch face. The simplest "fix" is to centre everything:
<layout id="RouteLayout">
<text-area id="routeInfo" width="fill" height="fill" justification="Gfx.TEXT_JUSTIFY_CENTER" />
</layout>
Yeah, I'm pretty happy with that. I'll tag that as
METROLINK_MC_WIRING_UP.
Fixing the Timing Issue
Earlier, I noted that we were effectively in an infinite loop and that, while it wasn't the end of the world, it seemed a bad plan. So let's fix that by using a
Timer.
My plan is that
onUpdate will be called when the system feels that it is appropriate (this is already happening). When this happens, we will add a timer to say, "try again in 30s". When we come into
onUpdate again, we will test if this timer is still active and, if so, we will
not fire the web request. When the timer fires, we will clear it and then call
onUpdate, thus making another web request and setting up another timer. It doesn't seem like that should be too hard, does it?
And indeed it isn't.
class metrolink_watchView extends WatchUi.View {
var route;
var textArea;
var timer as Timer.Timer?;
...
function onUpdate(dc as Dc) as Void {
if (timer == null) {
...
timer = new Timer.Timer();
timer.start(method(:timerCallback), 15000, false);
...
function timerCallback() as Void {
timer = null;
WatchUi.requestUpdate();
}
Probably the most interesting thing of note is the syntax to declare a variable to either have a value or be null:
Timer.Timer?. Also, the existence of a
Timer class inside a
Timer package caused me some confusion (I originally wrote
var timer as Timer, and had two errors to fix).
It's also interesting how hard it is to make sure that everything is actually working; I had to add various debugging statements to make it clear that the request was being invoked and the display updated (not shown), but in the end I convinced myself I had done it correctly. So now it updates every 15s - probably that's a little too often, but I'll experiment and see if it causes me any issues with battery life.
I also added code to cancel the timer in
onHide(). Unlike the lifecycle methods in the app class, there is a clear menu item (
Settings>Force onHide) that causes this to be called. I suspect that some lifecycle operation will cancel it for me anyway, but I thought this would probably be on the safe side. In my experiments, it certainly doesn't seem to do any harm. The corresponding
Settings>Force onShow starts the widget running again.
function onHide() as Void {
if (timer != null) {
timer.stop();
timer = null;
}
}
Time to deploy it to the actual device again. Cool. Tagged as
METROLINK_MC_WITH_TIMER.
A PHP Bug
At this point, I tried adding some more routes to my configuration and suddenly encountered no data coming back. A little investigation revealed that the problem was I never finished the work of handling multiple "to" stations, and so if there were no trams going to the first destination, no data came back. Fortunately not at all subtle.
I went back and fixed that.
// Apply the destination filters
if ($this->to) {
if (!array_key_exists($d, $item)) return false;
$isTo = $item[$d];
$anyTo = false;
foreach ($this->to as $f) {
if ($isTo == $f)
$anyTo = true;
}
if (!$anyTo) return $anyTo;
}
// Apply the origin filters
Apparently, I didn't write any tests. Oh, well. Checked in as
METROLINK_PHP_MULTIPLE_TO.