Sunday, October 13, 2024

Scrolling through the Route


So far, none of the route information I've seen has actually required scrolling, but I don't think I can rule it out. On the other hand, I'm very tempted to ignore it because while I am used to the idea of scrolling being handled by the UI toolkit, it seems that this is very much not the case here. So if I am going to do it myself, I am left having to hand-roll it.

Normally, I would consider all my options (sorting the data so that the trams appear in order would be one way to make sure the most relevant information does appear on the screen), but given the nature of this blog and what seems to be involved, I think I am just going to go for it.

Up until now, we have been using the UI toolkit with Layout and built-in components (specifically the TextArea). But it would seem it is also possible to draw directly to the device using the Dc passed into onUpdate. This is, obviously, going to be much harder than just saying "here, TextArea, show this string", but I'm up for the challenge if you are, especially since there are a couple of samples out there on the internet.

So let us go back to square one. I'm going to remove the whole of onLayout and comment out the variable textArea and all it's usages. I'm only going to comment these out - rather than remove them - to remind myself of what the logic should be. When everything is wrapped up, I'll delete anything that's left, but in the meantime I suspect I may just end up slipping in an alternate definition of textArea.

So, starting with onUpdate, we can try showing the "Please Wait..." message without using the textArea. We have a drawing context, dc, and this has (among other things) a drawText method.

This conveniently seems to do centering for us, so if we can find the centre of the screen, we can presumably do what we want fairly easily. Fortuitously, there is an example of doing just that earlier on the page, and the dc object has getWidth and getHeight methods.
    if (showWait) {
      // textArea.setText("\n\nPlease Wait.\nLoading Data...\n");
      showWait = false;
      dc.drawText(
        dc.getWidth() / 2,
        dc.getHeight() / 2,
        Graphics.FONT_SMALL,
        "Please Wait.",
        Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER
      );
    }
Sadly, this doesn't seem to work. It's not immediately clear why. But thinking through what is happening here, I'm aware that onUpdate is called in a context, and the drawing context passed in needs to be established and colors set. Let's try something simpler. Let's set the foreground color to be white to be sure, and then let's try and draw a box in the lower-right (3 o'clock to 6 o'clock) portion of the display.
  function onUpdate(dc as Dc) as Void {
    dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
    dc.fillRectangle(dc.getWidth()/2, dc.getHeight()/2, dc.getWidth()/2, dc.getHeight()/2);
    if (showWait) {
No, nothing doing. Reviewing the code and thinking about the logic some more, I realize that there is a not-completely-innocous call to View.onUpdate() at the end of my onUpdate. It even has an associated comment that it redraws the screen. Now, up until now I have wanted that at the end of my function so that any updates I may have made to the TextArea are applied before it is redrawn. But now I am not using the layout but redrawing directly, that is probably clearing off the screen the moment I have drawn to it. Let's reverse that, and call it first.
  function onUpdate(dc as Dc) as Void {
    // Call the parent onUpdate function to redraw the layout
    View.onUpdate(dc);
    
    dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
    dc.fillRectangle(dc.getWidth()/2, dc.getHeight()/2, dc.getWidth()/2, dc.getHeight()/2);
Well, at least we get the box. Reviewing the code again, I notice that there is a showWait variable that I've used to make sure that we don't show the "waiting..." message once we have data; but if we come through onUpdate more than once, we will clear off the message. Removing the assignment to false, the code magically works.
    dc.fillRectangle(dc.getWidth()/2, dc.getHeight()/2, dc.getWidth()/2, dc.getHeight()/2);
    if (showWait) {
      // textArea.setText("\n\nPlease Wait.\nLoading Data...\n");
      // showWait = false;
      dc.drawText(
        dc.getWidth() / 2,
        dc.getHeight() / 2,

Let's Throw All That Away

I've checked all that in on a branch (the HEAD is tagged $METROLINK_MC_ALWAYS_SHOW), but only so that I can have it there to show on this blog. I'm going to throw it all away - I went down a blind alley.

Admittedly, I did what I did for good reasons - I could not scroll a text area, so I needed to replace it with something else. It seemed the simplest thing was to draw directly on the screen, but actually that isn't so simple. To carry on from where I was, I would need to figure out how to persist the message to be displayed so that I could keep onUpdate reasonably simple. And that would ultimately lead to another abstraction. Without even doing it, I can see that I would end up reimplementing the whole of the layout mechanism. Why?

The motivator, of course, is that I can't use (as far as I can tell) my own components with the XML layout compiler in Monkey C. But just because I can't use their compiler doesn't mean I can't use my own layout. The compiler in fact generates very simple code. This is to be found in $bin/gen/.../source:
module Rez {
    module Drawables {
      ...
    } // Drawables


    module Layouts {
        ...
        function RouteLayout(dc as Graphics.Dc) as Array<WatchUi.Drawable> {
            var rez_cmp_local_textarea_routeInfo = new WatchUi.TextArea({:identifier=>"routeInfo", :width=>240, :text=>"", :justification=>Gfx.TEXT_JUSTIFY_CENTER, :height=>240, :font=>[Graphics.FONT_MEDIUM] as Array<Graphics.FontType>});


            return [rez_cmp_local_textarea_routeInfo] as Array<WatchUi.Drawable>;
        }
    } // Layouts


    module Strings {
        ...
    } // Strings
} // Rez
And let's face it, that's really quite a simple piece of code. It's basically just creating and initializing a text area and packaging it up in an array. I could do that myself.

So winding back to where we were, I'm going to extract that code and put the line that creates the TextArea in my intialize constructor and put the array line in onLayout and check that everything still works without the XML file being involved.
  function initialize(routes as Array<Route>) {
    self.routes = routes;
    self.textArea = new WatchUi.TextArea({:identifier=>"routeInfo", :width=>240, :text=>"", :justification=>Graphics.TEXT_JUSTIFY_CENTER, :height=>240, :font=>[Graphics.FONT_MEDIUM] as Array<Graphics.FontType>});
    View.initialize();
  }

  function onLayout(dc as Dc) as Void {
    setLayout([self.textArea] as Array<WatchUi.Drawable>);
  }
Unsurprisingly, that now works. So I want to replace the concept of a TextArea with a ScrollArea which is a component which knows how to draw itself and works in every way like a TextArea except it also has methods to scrollUp and scrollDown when we swipe up and down.

ScrollArea

Looking at the manual page for TextArea, I can see that it inherits from WatchUi.Drawable, so I know that I want to do the same. So let's create a skeleton class for ScrollArea and use that instead of TextArea in our view:
import Toybox.WatchUi;
import Toybox.Lang;

class ScrollArea extends Drawable {
  var tx as String?;

  function initialize(options) {
    Drawable.initialize(options);
  }

  function setText(tx as String) {
    self.tx = tx;
  }
}
And that "works" as long as you don't want the text to display.

In order to have the text display, it is necessary to add a draw method to the ScrollArea class, and the simplest implementation of that is just to repeat what we did above directly in the view's onUpdate method. As we learnt before, if you want to be able to read the text, you need to specify a drawing color.

Either unsurprisingly, or amazingly (depending on your general optimism level), that's all we need to do to replace TextArea: the drawText method automatically handles newline processing and centering.
  function draw(dc as Dc) {
    System.println("in draw with " + tx);
    if (tx != null) {
      dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
      dc.drawText(
        dc.getWidth() / 2,
        dc.getHeight() / 2,
        Graphics.FONT_SMALL,
        tx,
        Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER
      );
    }
  }
So all we need to do now is enable this to scroll.

We already have an InputDelegate listening for these events, so we just wire up the SWIPE_UP and SWIPE_DOWN events, redirecting them to the view. The view, in turn, handles these events by passing them off to the ScrollArea and then requesting the view to be refreshed. The events are handled in the ScrollArea by either incrementing or decrementing a (scroll) offset, and then the y position in draw is determined by subtracting a quarter of the screen height for every unit of offset.

Everything here is about coordinates and directions: SWIPE_UP and SWIPE_DOWN act in the opposite manner to which I would expect, so I translate SWIPE_UP to scrollDown. This increments offset, because we want to be further down the scroll, but we achieve that by saying we start drawing the (center of) the scroll further up the screen. It might be clearer to not use the word "scroll" at all and connect SWIPE_UP to viewUp which decrements offset, which can then be added to the y value. Or not.

In the navigation handler, we have:
    case SWIPE_UP: {
      view.scrollDown();
      break;
    }
    case SWIPE_DOWN: {
      view.scrollUp();
      break;
    }
In the view, we add:
  function scrollUp() {
    textArea.scrollUp();
    WatchUi.requestUpdate();
  }

  function scrollDown() {
    textArea.scrollDown();
    WatchUi.requestUpdate();
  }
We add these methods in the ScrollArea:
  function scrollDown() {
    offset ++;
  }

  function scrollUp() {
    offset --;
  }
}
And then wrap it all up in draw:
  function draw(dc as Dc) {
      dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
      dc.drawText(
        dc.getWidth() / 2,
        dc.getHeight() / 2 - (dc.getHeight() * self.offset)/4,
        Graphics.FONT_SMALL,
I feel that more should be done here - specifically, there should be some kind of "bounds" within which the offset should be kept, but, to be quite honest, I don't care enough. If you do, add it. If I do later, I'll add it. Frankly, there are a bunch more things I'd like to add - but even so, I'm not going to right now. For now, I need to get this blog published :-)

So I checked that in and tagged it as METROLINK_MC_SCROLL_AREA.

Friday, October 11, 2024

Remembering the Current Route


Now that we have support for multiple routes and we can choose one of them, it's important to be able to remember which one we've chosen so that we can show it the moment the widget is selected. Monkey C provides Device Storage for this, so let's use that now to store the value of currRoute.

This is a basic singleton class that essentially has methods for getting and setting values in the application storage. As you might expect, getValue returns null if the information is not there.

So we can easily add setValue to our next and previous methods:
  function previousRoute() {
    self.currRoute --;
    if (self.currRoute < 0) {
      self.currRoute = self.routes.size()-1;
    }
    Storage.setValue("currRoute", currRoute);
    reset();
  }

  function nextRoute() {
    self.currRoute ++;
    if (self.currRoute >= self.routes.size()) {
      self.currRoute = 0;
    }
    Storage.setValue("currRoute", currRoute);
    reset();
  }

And then in the constructor we can retrieve it. If it isn't there, we default to 0:
  function initialize(routes as Array<Route>) {
    self.showWait = true;
    self.routes = routes;
    var stored = Storage.getValue("currRoute");
    if (stored) {
      self.currRoute = stored;
    } else {
      self.currRoute = 0;
    }
    View.initialize();
  }
That's checked in as METROLINK_MC_ROUTE_STORAGE and it's time to deploy to the watch again.

On the Main View

So this works in the way in which I designed it, but one thing I can do on the watch that I haven't yet been able to do in the simulator is "leave" the nested view (I push the physical button). I notice that when I do this, it "forgets" the selected route and goes back to the route it was previously thinking of. Looking at the code, presumably the initialize method is not called again when a view is "popped", but onShow is. So the simple fix is to move the recovery of currRoute into onShow. It's also necessary to make sure that the query is fired again, since the data we have on hand may be out of date (it would probably be possible to get the child to pass back its current data, but I'm not sure I'm up to that).
  function onShow() as Void {
    var stored = Storage.getValue("currRoute");
    if (stored) {
      self.currRoute = stored;
    } else {
      self.currRoute = 0;
    }
    self.timer = null;
    self.showWait = true;
  }
This is then tagged in as METROLINK_MC_CURRROUTE_ONSHOW.

Wednesday, October 9, 2024

Choosing a Route

At some level, that feels like most of the app done. But one of the things that I really want to be able to do is to support multiple routes, and that requires the ability to handle input and multiple views.

The overview of widgets in the documentation makes it quite clear that there is a distinction between the "initial view" and subsequent views pushed on top of that. In particular, it is not possible to swipe or scroll the initial view.

Consequently, my thought (and the way a lot of apps seem to be designed) is to have an initial view with some information on it along with a button to open up a swipable view that allows you to rotate (horizontally) through all the routes and scroll (vertically) if a route has too much information to fit on one page.

I think this is going to involve a lot of steps, but I think I'm going to start with trying to handle some input and just print things to the console.

Adding an input handler

Monkey C provides for an input handler to be applied to the initial view by having it be the second member of the array returned by getInitialView. So we can easily define a class to be our input handler and then create an instance of it in getInitialView and pass that back.

I am going to define a BehaviorDelegate because that seems the most promising option from the types of input handlers and specifically I think I want to choose the onSelect method as the one which responds to a tap on the screen which is what will prompt me to open a new view.

This is pretty much the simplest thing I can build:
  function getInitialView() as [Views] or [Views, InputDelegates] {
    return [ new metrolink_watchView(routes[0]), new metrolink_openRoutes() ];
  }
adds the input handler to the initial view. And then
using Toybox.WatchUi;

class metrolink_openRoutes extends WatchUi.BehaviorDelegate {
  function initialize() {
    BehaviorDelegate.initialize();
  }

  function onSelect() {
    System.println("on select...");
    return true;
  }
}
defines the behavior delegate, with onSelect defined to print a message.

In testing this on the simulator, I found that clicking on the watch face prompted my message to come out, while dragging seemed to simulate a swipe (dragging to the right did nothing; every other direction caused my widget to quit).

I would try this on the watch itself, but I don't think I would see any feedback at the moment.

This is checked in as METROLINK_MC_INPUT_HANDLER.

Pushing a new view

Now that we have a "hook" to attach it to, let's see if we can create a new view which can handle drag events. At the moment, it seems to me that we want these views to all be very similar: so similar, in fact, that I think I am going to start off by just reusing exactly the same view code. I think what is going to happen is that ultimately I am going to end up extracting something (probably a base class but possibly a configurable class) which contains the common code and then have multiple other classes to configure that. Bear with me while this quite possibly gets ugly: remember, I'm learning too.

The key to this is the WatchUi.pushView() function, which is basically the same as getInitialView, except it is something you call, rather than being called. So we want to call this and pass it the same view, but a different handler - one that will be able to handle dragging.

After some research and experimentation, it doesn't seem that the BehaviorDelegate can handle the kind of interaction I want, so I have ended up choosing the more primitive InputDelegate and then I'm going to implement the onSwipe method in order to look at all four directions I could swipe. On my watch at least, I will then be able to go back to the main menu by pushing the physical button. No, this is not portable. But then I don't have access to a sufficiently wide range of devices to figure out what the best approach is.

So, onSelect pushes the view so:
  function onSelect() {
    System.println("on select...");
    WatchUi.pushView(new metrolink_watchView(routes[0]), new metrolink_navigate(routes), WatchUi.SLIDE_IMMEDIATE);
    return true;
  }
And the new InputDelegate looks like this:
using Toybox.WatchUi;
import Toybox.Lang;

class metrolink_navigate extends WatchUi.InputDelegate {
  var routes;

  function initialize(routes as Array<Route>) {
    self.routes = routes;
    InputDelegate.initialize();
  }

  function onSwipe(ev) {
    System.println(ev.getDirection());
    return true;
  }
}
Checked in as METROLINK_MC_PUSH_VIEW.

Swiping through the routes

So the big goal is to swipe through the routes. It feels odd to me to have an InputDelegate in one hand and a View in the other, rather than having them be connected. I'm not sure why, since this seems completely in line with the MVC pattern; possibly just because this is the first time I am using this language and toolkit. Anyway, I am going to couple them together at this point by creating them outside the call to pushView() and then telling the InputDelegate about the View. I'm then thinking that when events arrive at the InputDelegate, it will call corresponding methods in the View.

I'm not at all sure this is what the designers intended, and it's entirely possible that I will end up deciding that there is a better way to do this, for example to have a "model" object held in common that the InputDelegate updates and then calls WatchUi.requestUpdate() and then the onUpdate method interrogates the model. Indeed, it seems that that would especially be the case if I found myself storing the data (the model) in local storage. So much so that I'm almost talking myself into doing it!

For now, I'm just going to wire up the actions and check (in the simulator) that I can see the events being processed within the View. I'll leave it until the next section to actually try changing what's displayed.

So, in onSelect in the initial view, we push the wired up navigation view.
  function onSelect() {
    System.println("on select...");
    var view = new metrolink_watchView(routes[0]);
    var handler = new metrolink_navigate(routes, view);
    WatchUi.pushView(view, handler, WatchUi.SLIDE_IMMEDIATE);
    return true;
  }
In the swipe handler, we call back into the view:
class metrolink_navigate extends WatchUi.InputDelegate {
  var routes;
  var view;

  function initialize(routes as Array<Route>, view as metrolink_watchView) {
    self.routes = routes;
    self.view = view;
    InputDelegate.initialize();
  }

  function onSwipe(ev) {
    System.println(ev.getDirection());
    switch (ev.getDirection()) {
    case SWIPE_RIGHT: {
      view.previousRoute();
      break;
    }
    case SWIPE_LEFT: {
      view.nextRoute();
      break;
    }
    }
    return true;
  }
}
And in the view, we just print tracing statements:
  function previousRoute() {
    System.println("previous");
  }

  function nextRoute() {
    System.println("next");
  }
It's an interesting thing - if not a religious debate - as to which direction a "swipe" is or "scroll" is. I feel that - at least with scrolling - the clue is in the name. "Scroll Up" means that the scroll moves up relative to the viewport, so what you see in the window is text which is lower down the scroll. It's less clear to me what exactly "swipe right" means, but I think I would expect it to be the motion of swiping from left to right; but of course, that shows the content to the left, so it is equally plausible to describe that as "swipe left", which is how Garmin describe it. So when I "swipe right", I want the previous page, not the next page as I would expect.

This is checked in as METROLINK_MC_HANDLE_SWIPE.

Choosing the correct route

So, a quick confession: I thought that in metrolink_navigate, we were going to need the routes so that it could choose between them. But in actual fact, I think I've finished coding that and we don't, so I'm going to remove it. Meanwhile, the metrolink_watchView does require all the routes so that it can choose between them.

Apart from all that, we need a counter, currRoute that tells us which route we are looking at right now. This starts at 0, and can count up and down but must always stay in the bounds of 0..routes.size() (I've often though it would be good to have a BoundedCounter class to do this, and today was nearly the day, but no). Finally, we need a reset method which stops and clears the timer (so that the onUpdate method will call to the server for the new route), sets showWait to true (so that the user gets immediate feedback that a new route is being selected) and requests a view update (since this is where all the magic happens).
  function previousRoute() {
    System.println("previous");
    self.currRoute --;
    if (self.currRoute < 0) {
      self.currRoute = self.routes.size()-1;
    }
    reset();
  }

  function nextRoute() {
    System.println("next");
    self.currRoute ++;
    if (self.currRoute >= self.routes.size()) {
      self.currRoute = 0;
    }
    reset();
  }
...
  function reset() as Void {
    timer.stop();
    timer = null;
    showWait = true;
    WatchUi.requestUpdate();
  }
Checked in and tagged as METROLINK_MC_CHOOSE_ROUTE.

Startup Screens


What I really want to do at this point is support multiple routes, but I'm (more than) a little bit annoyed by how the app behaves on the watch itself. First, a screen appears that has an "IQ" logo and the words "metrolink-watch". Then the screen goes blank. For quite a while. Sometimes forever.

My interpretation of this is that the first of these is a splash screen which (hopefully) I can configure. If I can, I should.

Then there is a delay while the query for the data happens, during which time the screen is blank; we should change that. I believe that generally we will get some answer back, even if it is that the query timed out, so the "forever" is probably when the query fails but we don't handle the response properly.

In fact, there are two possibiities: there is an error of some form which we should report; or there is simply no data, which should be interpreted as "no trams currently running on those routes", which we should explain.

Splash Screen

Tackling the splash screen first, there are a couple of parameters in the manifest which enable you to configure how this looks. The name is a reference to a string resource, and the launcherIcon is a reference to a drawable resource. Hopefully if I configure those, I can get a picture of a nice yellow tram and the words "Tram Info" to appear.

I downloaded an image from the internet with the Bee Network logo and saved it as metrolink.png. I then changed the drawables.xml resource file to reference this. Then in strings.xml I changed AppName to be Next Tram, which is the information which this app is intended to provide.
<drawables>
  <bitmap id="LauncherIcon" filename="metrolink.png" />
</drawables>
<strings>
  <string id="AppName">Next Tram</string>
</strings>
Since the splash screen does not appear in the simulator (it shows a blue triangle while loading the widget), I compiled it and downloaded it onto the watch. I understand that it is both important to verify apps that are downloaded and that it is going to be slow on a low-power device, but it is painful to sit and wait for it to happen. This is not my kind of development cycle. Eventually, however, I can see an icon (it's somewhat indistinct, so I think I'll ultimately be designing something else) and the words Next Tram.

I have checked those changes in as METROLINK_MC_SPLASH_SCREEN.

Status

But that only lasts for a moment before the screen goes completely black. I suspect that this is the moment it renders my view. So, by the time onUpdate returns, I need to have displayed a message saying "Please Wait, Loading Data..." or some such. At the same time, it's very important that this message does not scribble over existing data when we are re-loading the data from the server.

So I'm going to add a variable showWait which is initially true; if this is true in onUpdate, the waiting message will be shown and the showWait variable set to false. I don't fully understand the lifecycle yet, so I'm going to assume that setting it in initialize is reasonable enough. If it doesn't work the way I expect, I will come back and do something differently.
using Toybox.Timer;

class metrolink_watchView extends WatchUi.View {
  var showWait;
  var route;
  var textArea;
  var timer as Timer.Timer?;

  function initialize(route as Route) {
    self.showWait = true;
    self.route = route;
    View.initialize();
  }

  // Update the view
  function onUpdate(dc as Dc) as Void {
    if (showWait) {
      textArea.setText("\n\nPlease Wait.\nLoading Data...\n");
      showWait = false;
    }
    if (timer == null) {
      var params = {};
      if (route.from) {
This is checked in as METROLINK_MC_PENDING_SCREEN.

In order to handle the error screen, I temporarily fiddle with the route information to make it impossible to find any trams. Running the app again, it just sits there saying "Please Wait...". Not what we want.

Because of the way in which PHP represents associative arrays, an empty dictionary is indistinguishable from an empty array, and json_encode encodes it as an empty array. Consequently, in onReceive, we can spot "no trams found" case as an array rather than a dictionary. We already test for this case so that assembleMessage does not fail. So we will simply display a different message which explains that no trams could be found for the specified route.

Finally, if we receive an error code, I am going to display a message that doesn't explain anything but includes the response code. Hopefully knowing that an error happened is enough, since there probably isn't anything you can do right now.
    if (responseCode == 200) {
      if (data instanceof Dictionary) {
        textArea.setText(assembleMessage(data));
      } else {
        textArea.setText("\nNo trams currently found on the\n" + route.name + "\nroute");
      }
      WatchUi.requestUpdate();
    } else {
      textArea.setText("Error querying server.\nResponse Code:\n" + responseCode + "\nroute\n" + route.name);
    }
Checked in as METROLINK_MC_ERROR_CONDITIONS.

No BLE Connection Available

The moment I put this onto the watch, I see an error message, code -104. Comparing this to the list of constants in $Toybox.Communications$, I see that it says that it is because there is no BLE Connection Available. In other words, the watch needs to talk to my phone and can't.

This is odd, because my watch is receiving other phone notifications, and it hasn't had this problem before. But I'm not really here to try and debug my watch (or phone), but to make an app. And it seems that, in spite of saying above that I wasn't going to try and identify all the different cases, this one is special enough that I want to have a special message for it. I know that the moment I do this, the situation will never arise again and I will not see the message - but I can force it to happen by turning off bluetooth on my phone.
      switch (responseCode) {
        case Communications.BLE_CONNECTION_UNAVAILABLE:
          msg = "\nCannot connect to phone using Bluetooth";
          break;
        default:
          msg = "Error querying server.\nResponse Code:\n" + responseCode + "\nroute\n" + route.name;
          break;
      }
Checked in as METROLINK_MC_NO_BLE_ERROR.

Tuesday, October 8, 2024

Metrolink Application on the Watch


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.

Getting started with Monkey C

OK, now onto the fun part. I've written lots of PHP code before and I generally find it painful. I have to say, the combination of a small project, the project being essentially greenfield, using the latest PHP features and "doing it right" (rather than just hacking something together) actually made that a relatively pleasant experience.

But what I really want to do is to build an app for the watch; as I said, it's something I've been wanting to do for about seven years now. So, let's get started.

Installing the SDK

I'm not sure there's any particular merit to my decisions, but I opted to do all the PHP work on my linux box (where I have PHP installed and it was easy to install other components such as PHPUnit) but have opted to do the watch side of things on my Mac, since Mac is a supported environment for Monkey C (so is linux, for what it's worth).

There is a Getting Started page which I am basically paraphrasing here, although similar instructions are on the download page.

Head over to the Garmin SDK Download page and agree to their terms and click to download the installer. This requires you to have an account and log in - I have done this previously when trying to get things to work, so I can just sign in.

It then downloads the current SDK and a whole bunch of metadata about devices. Fortunately, it downloads the SDK first, so I think we can carry on while it downloads all the devices.

Switching our attention to VSCode, we now install the "Monkey C" extension, complicated a little by the fact that there seem to be a few with the same name; I'm going to install the official Garmin one. It apparently then needs VSCode to restart, and then wants us to "verify the installation" by running the command "Monkey C: Verify Installation". I can do that. I think. It turns out that this requires me to have a "developer key" which was not mentioned in the instructions and is not obviously implied by "verify installation" (in the way it would be by "complete" or "configure" installation). I guess everything depends on context.

I may have generated a key 7 years ago, but if so, I've probably lost it or at least misplaced it (I don't take these things as seriously as I should to begin with, before even thinking about the fact I was just messing around, and that is two computers ago now), so I opt to generate a new one and save it in "a safe place". Hopefully I'll be able to find it again. Then everything seems fine.

The final set of instructions are about installing the SDK for your phone, which I don't want to do right now, so I won't.

Let's Build Something

So there is documentation at the Monkey C page which describes a simple application, but I'm going to follow the instructions at Your First App in order to build something quickly.

So, using CMD-SHIFT-P and Monkey C: New Project, I create a project called metrolink-watch which will be a widget. It then asks what API level I want to use. Normally, I would pick the highest number to be compatible as far into the future as possible; but on this occasion, I would like it to be compatible with the watch I already own. Looking at the compatibility page, I can see that my watch uses API level 3.1.0, so I will pick that one. It then asks me which devices I'm interested in supporting, so I select the "vivoactive 3". It then asks me for a directory. This is presumably the directory where it is going to put my project. Is it going to create a subdirectory, or just put the project there? I don't know, so I play it safe and create a new directory for it ... but no, it creates its own subdirectory. So now I have metrolink-watch/metrolink-watch. OK, delete all of that and start again.

Very good. We now have a project on the disk, in VSCode and incorporated in the workspace. Just what I wanted. The manifest file has also been opened in a special manifest editor. I'm not sure whether I wanted that or not, but I suppose it's not unreasonable.

(By now, the installer has finished its work so I have closed it.)

The continuing instructions on getting started tell me how to configure the manifest, but all of those options are already taken care of, so that's fine. So let's try running it in the simulator and see what happens. The instructions say use CMD-F5 on mac, but that seems to launch "VoiceOver" on my machine. VSCode suggests trying CTRL-F5, but that tells me something about npm not being available. From the Run menu, I get the same message. I try the context menu, and that has an option to "Build Current Project", so I try that, which seems to work, but still doesn't offer me the ability to run the program (it does have the option to run tests). Even when I point at the "binary" (metrolinkwatch.prg), it doesn't have an option to "run me". What am I missing?

(At this point I notice the instructions say, "if all goes well..."; presumably they aware that things not going well is a possibility, but not one they choose to dwell on).

In desperation, I try Run Unit Tests, which at least pops up a window, but doesn't seem to do anything else, presumably because I don't yet have any unit tests (maybe I should write some?).

I still feel I struggle a lot with VSCode and what you need to set it up. I feel that I am missing some kind of launch configuration or task, just like I was with running the phpUnit, and I don't know where to configure it. So let's go google.

On the page about the Monkey C Visual Studio Code Extension, there is a reference to launch.json, which attracted my attention (well, Google's), but before I get to the section on "Editing the Launch Configuration", I see this line:
Before running the program, make sure you have one of your source files (In the source folder with the .mc extension) open and selected in the editor.
which, I have to confess, is also in the instructions I was claiming to follow, but somehow escaped my attention. But no, that makes no difference.

OK, so where would I look for a launch.json? Well, in my .vscode directory in my workspace. It turns out I have four in other projects. I have no idea how VSCode decides which one(s) to use, so I move them all out of the way. Now when I push CTRL-F5, things happen and (eventually) a watch with a monkey in it appears on my screen. Woohoo!

(Interestingly, no launch.json seems to have been generated; if it has, I don't see where it has been put.)

I realize that I have not spent the time I should understanding all of the interactions between system installations, workspaces and projects that I should in VSCode, but this confuses me no end. If it has found five launch configurations, it should have some algorithm to figure out which one to use. Asking me seems like the obvious way, but I would accept "the one in this project". Randomly picking one of the four other projects and then complaining it doesn't work seems ridiculous in the extreme.

So I did some research, and it seems that launch configurations need to be defined in their various launch.json files, but then these can be named within the workspace file and possibly this allows you to see a menu of available launch configurations. But at the same time, I noticed that there is a menu option to create a new launch configuration for a project (Run>Add Configuration...).

When I run this with all my launch.json files moved out of the way, it sensibly creates one in metrolink-watch. But when I do it with an existing launch.json in the workspace, it attempts to add it to that one. So I'm forced into hacking things. I create a new .vscode/launch.json in the metrolink-watch directory, and put in some completely boilerplate content (the version and configuration tags, with minimal content). Now when I select the Run>Add Configuration... and choose Monkey C: Run App, it creates something reasonable for me - but it still put it in the existing one. Still, I can cut-and-paste with the best of them.

OK, for the time being at least, that seems to work, even with the other configurations in place, as long as I am in one of the .mc files.
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "monkeyc",
      "request": "launch",
      "name": "Run App",
      "stopAtLaunch": false,
      "device": "${command:GetTargetDevice}"
    }
  ]
}
Before going any further, I'm going to check that in and tag it as METROLINK_MC_SAMPLE.

On the Watch

The final part of the instructions gives information about "sideloading" an app: that is, loading onto your device without going through the store. So let's try that.

The first step is, reasonably enough, to plug the watch in to the computer. Easy enough.

Then I need to "build for device". I'm confused why the existing build is not enough. Does it need different packaging for simulators and devices? That seems a poor design choice. The instructions say that it will ask you to choose a device, but it didn't ask me (maybe because I only selected one during configuration?) and then asks for an output directory. I'm not sure why it doesn't just put it in bin/ but presumably there's a reason. So I create a new folder called vivoactive3 and click open. Then, it quietly puts another question at the top of the screen: Debug or Release. I didn't notice this the first couple of times through and was very confused as to what was going on (which seemed to be nothing). Eventually I selected Debug and my vivoactive3 directory was populated with a metrolinkwatch.prg file.

Following the instructions, I then copied that across to the device:
$ cp vivoactive3/metrolinkwatch.prg /Volumes/GARMIN/GARMIN/APPS/
Again, this seems a little confusing: the instructions say copy to your device's GARMIN/APPS directory but doesn't give the path to the device. This is apparently /Volumes/GARMIN/, so the full directory path ends up having two GARMIN segments.

And now I can do an ls on that directory:
$ ls -1 /Volumes/GARMIN/GARMIN/APPS/
DATA
LOGS
MAIL
OUT.BIN
SETTINGS
TEMP
metrolinkwatch.prg
OK, this is further I have ever managed to get before.

Turning to the watch, I find it seems "locked" in a weird mode. Presumably this is it's "I am currently connected to a PC" mode. I try unmounting the device, but that doesn't work. I then unplug it and it says "Verifying ConnectIQ apps". Very good. Then it goes back to normal mode. I slide through the widget carousel and the "last" or "bottom" one (as I think of it) says "IQ metrolink-watch" and then less than a second later shows me a picture of a monkey. Excellent. Not quite "hello, world", more sort of "hello, monkey".

But now I can start developing the app.

Tuesday, October 1, 2024

PHP to read metrolink data


I already have PHP installed on my linux box; I think this is the default. I have previously installed Visual Studio Code (VSCode) and I think that's a fairly common thing to have installed. I don't already have a plugin for PHP on VSCode, but I'm installing the DevSense PHP plugin right now from the "extensions" tab of VSCode.

So I can now write what I would consider the minimal code to read the metrolink data feed:
<?php
  require('metrolink_key.php');

  $ch = curl_init("https://api.tfgm.com/odata/Metrolinks");
  curl_setopt($ch, CURLOPT_HTTPHEADER, array($metrolink_key));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
  $response = curl_exec($ch);
?>
The require statement incorporates the security key you need in order to access the OData feed. This is not present in the repository since it is a secret, but the samples directory has a file metrolink_key_template.php which shows you what it would look like. Then the next four lines access the OData feed, giving the URI, the security key, specifying that we would like the output to be returned as a value from the function call (rather than being sent directly to the client) and then executing the request.

In theory at least, this should run on our local machine:
$ php metrolink-data.php
PHP Fatal error: Uncaught Error: Call to undefined function curl_init() in /home/gareth/Projects/IgnoranceBlog/metrolink-php/php/metrolink-data.php:4
So it would seem that while I have php installed, I don't have the PHP curl module installed. It would seem that the required fix on linux is:
$ sudo apt-get install php-curl
After this, I can try again and get the expected absence of any output:
$ php metrolink-data.php
Checked in and tagged as METROLINK_PHP_MINIMAL.

Transforming the data

The next step is to try and do this transformation. I want to write some PHP tests around this (I've never used phpUnit before, so this will be interesting in that way, too), but at the top level I want to translate the JSON response into objects, and then I want to transform the resultant objects into JSON. None of that is going to be tested, but the core is a transform() function which I do want to test, and to keep things straight, I'll put that in its own file.

The main program now looks like this:
<?php
  require('metrolink_key.php');
  require('transform.php');

  $ch = curl_init("https://api.tfgm.com/odata/Metrolinks");
  curl_setopt($ch, CURLOPT_HTTPHEADER, array($metrolink_key));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $response = curl_exec($ch);

  $data = json_decode($response);
  $transformed = transform($data->value);
  print json_encode($transformed);
?>
and the transform() function at this stage just returns some sample data:
<?php
  function transform($odata) {
  return array("Firswood" => array("Victoria" => ["19:59", "20:09"]));
  }
?>
Checked in and tagged as METROLINK_PHP_TRANSFORM.

Installing and using phpUnit

The first step is to install phpUnit, which is described here, but on closer inspection it seems that I can install it with apt:
$ phpunit
Command 'phpunit' not found, but can be installed with:
sudo apt install phpunit
I can do that.

I can now write a simple (failing) test:
<?php
use PHPUnit\Framework\TestCase;

require("../php/transform.php");

final class Transform_test extends TestCase
{
  public function test_something() {

    $foo = transform(array());
    // $this->assertInstanceOf("", $transformer);
    $this->fail("hello, world");
  }
}
and run it on the command line:
$ phpunit *
PHPUnit 9.5.10 by Sebastian Bergmann and contributors.


F 1 / 1 (100%)


Time: 00:00.004, Memory: 4.00 MB


There was 1 failure:


1) Transform_test::test_something
hello, world


/home/gareth/Projects/IgnoranceBlog/metrolink-php/test/transform_test.php:12


FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
But this doesn't seem to run inside VSCode. Googling around brought me to this article, which has cryptic instructions I don't understand, but I can try following:
$ composer require --dev phpunit/phpunit
Command 'composer' not found, but can be installed with:
sudo apt install composer
$ sudo apt install composer
...
0 to upgrade, 22 to newly install, 0 to remove and 9 not to upgrade.
Need to get 930 kB of archives.
...
$ composer require --dev phpunit/phpunit
Using version ^10.5 for phpunit/phpunit
./composer.json has been created
Running composer update phpunit/phpunit
Loading composer repositories with package information
Updating dependencies
...
And now I can run the tests in VSCode, using C-S-P and typing PHPUnit Test:
Executing task in folder vscode-java: "{php}" {phpargs} "{phpunit}" {phpunitargs} '/home/gareth/Projects/IgnoranceBlog/metrolink-php/vendor/bin/phpunit' --colors=always '/home/gareth/Projects/IgnoranceBlog/metrolink-php/php/transform.php'
/usr/bin/bash: line 1: : command not found
This is still not what I was expecting. It would seem that there are some configuration parameters that still need setting. I tried fiddling with various properties and settings, but nothing I did made it work.

Finally I gave up and decided to add a task. But I could not find where to put my tasks.json file. It turns out that in a VSCode "workspace" you don't put them in tasks.json but directly in the workspace, like so:
      "path": "metrolink-php"
    }
  ],
  "settings": {},
  "tasks": {
    "version": "2.0.0",
    "tasks": [
      {
        "label": "Metrolink PHP Tests",
        "type": "shell",
        "command": "phpunit",
        "args": ["*.php"],
        "group": "test",
        "options": {
          "cwd": "${workspaceFolder}/../metrolink-php/test"
        },
        "presentation": {
          "reveal": "always"
        }
      }
    ]
  }
}
And then it can be run with CTRL-SHIFT-P followed by Run Task followed by Metrolink PHP Tests. This is too much hassle for me, so I created a keyboard shortcut:
[
    {
        "key": "alt+f11",
        "command": "workbench.action.tasks.runTask",
        "args": "Metrolink PHP Tests"
    }
]
and placed this in the file ~/.config/Code/User/keybindings.json, which is where it would seem it is looked for on my system (I couldn't find a reference to this anywhere; I just created one in the editor to bind ALT-F11 to runTask and then searched for keybindings.json and edited it to add the task name).

OK, so now we can see our test fail inside VSCode:
Executing task in folder test: phpunit *.php


PHPUnit 9.5.10 by Sebastian Bergmann and contributors.


F 1 / 1 (100%)


Time: 00:00.004, Memory: 4.00 MB


There was 1 failure:


1) Transform_test::test_something
hello, world


/home/gareth/Projects/IgnoranceBlog/metrolink-php/test/transform_test.php:12


FAILURES!
Tests: 1, Assertions: 1, Failures: 1.
 * Terminal will be reused by tasks, press any key to close it.
And that is all checked in and "working" and tagged as METROLINK_PHP_PHPUNIT_FAIL.

Transforming the Data

The PHP function json_decode automatically transforms all of the JSON constructs to built-in PHP ones, and, in particular converts a JSON array to a PHP array and a JSON object to a PHP associative array. So by the time we have decoded the JSON and extracted the value property from the top level (which is done in the main code already shown), we pass an array of associative arrays to the transform() function, each of which has the information for one PID in it.

So the code for transform wants to iterate through this array, extracting and filtering the station location and destinations, and building up a map of trams between the two.

Hang on a sec! As I write "filter" I realize that I am missing a key part of code here, in that the top-level code needs to process the incoming request to identify the stations you want to travel between. That is, it needs to handle requests like:
metrolink-data.php?from=FIR&to=VIC&to=ROC
and when I say "like", I think over the course of time many more options will emerge, but for now the only requirement is that there must be at least one from and it is possible to have multiple from and multiple to parameters and to create a "grid" or "matrix" of times between the two.

That also means that it probably makes more sense to expose the Transformer class (which I was going to bury inside transform()) to the top level to take these parameters.
  $transformer = new Transformer($_GET);

  $ch = curl_init("https://api.tfgm.com/odata/Metrolinks");
  curl_setopt($ch, CURLOPT_HTTPHEADER, array($metrolink_key));
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $response = curl_exec($ch);

  $data = json_decode($response);
  $transformed = $transformer->transform($data->value);
Here we are passing the PHP superglobal $_GET into the Transformer constructor. What is that and what does it look like? It's an associative array of parameter names to values. You can see this in operation on the command line by dumping them out in the code:
  var_dump(value: $_GET);
and then running the program using the php-cgi binary:
$ php-cgi -f metrolink-data.php from[]=FIR from[]=VIC
array(1) {
  ["from"]=>
  array(2) {
    [0]=>
    string(3) "FIR"
    [1]=>
    string(3) "VIC"
  }
}
In order for this to work with multiple arguments with the same name, you will see that we have to give the argument name the array extension, [].

So, we can now rewrite our unit test to take this into account:

final class Transform_test extends TestCase
{
  public function test_extract_from() {
    $transformer = new Transformer(["from" => ["FIR", "VIC"]]);
    $this->assertEquals(["FIR", "VIC"], $transformer->from);
  }
}
And we can check all of that in as METROLINK_PHP_FIRST_TEST.

Some negative tests

It's fairly easy to knock out some "negative" tests, that is, tests for which the answer is always the empty map. These don't help us very much, but they do "ground" the code inasmuch as they stop us writing something that does not meet these criteria, and make sure that we have an empty array as the "default" value.
  public function test_no_pids_produces_no_output() {
    $transformer = new Transformer(["from" => ["FIR", "VIC"]]);
    $out = $transformer->transform([]);
    $this->assertEquals([], $out);
  }

  public function test_requiring_from_fir_excludes_from_air() {
    $transformer = new Transformer(["from" => ["FIR"]]);
    $out = $transformer->transform(["TLAREF" => "AIR"]);
    $this->assertEquals([], $out);
  }

  public function test_requiring_to_fir_excludes_to_air() {
    $transformer = new Transformer(["to" => ["FIR"]]);
    $out = $transformer->transform(["TLAREF" => "AIR"]);
    $this->assertEquals([], $out);
  }
}
And we need a trivial implementation of transform():
  function transform($odata) {
    return [];
  }
And this is all checked in as METROLINK_PHP_NEGATIVE_TESTS.

Testing the Loop

So we can now assert that if we receive a single entry with matching criteria, it is properly transformed.
  public function test_from_fir_includes_a_single_matching_entry() {
    $transformer = new Transformer(["from" => ["FIR"]]);
    $out = $transformer->transform([[
      "TLAREF" => "FIR",
      "Dest0" => "Victoria",
      "Wait0" => "6",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ]]);
    $this->assertEquals([["FIR" => ["VIC" => [ "19:58" ]]]], $out);
  }
(Note that in writing this test, I discovered that the previous tests had only provided one member, not a list, so changed them.)

Checked in as METROLINK_PHP_LOOP_TEST.

Filtering

For me, unit testing is always about how the code you've written is factored. In this case, there is basically a loop (which was tested above), then filtering, then the actual transformation. Having dealt with the loop, I now want to nail down (almost) all the cases to do with filtering.

Initially, at least, I want to offer three different cases of filtering: the from parameter should be a 3-letter code matching the TLAREF field, which is also the first three letters of the PIDREF field; the to field can be either the full name of the destination, i.e. one of Dest0, Dest1 or Dest2 (assuming those fields are there) or else its corresponding 3-letter code; or the to field can be a combination of the direction and line. For this last field, if you look at the data, you can see these two fields: the line is something like "Altrincham" or "South Manchester" and the direction is either "Incoming" or "Outgoing". In order to query this, the user must combine both into a single field, e.g. I-South Manchester or else O-Oldham & Rochdale. The idea of supporting this format is that you often know you want to go somewhere (e.g. Queens Road) but you don't know whether the tram is going to Bury, Heaton Park or Crumpsall - and you don't care. It's going the right way.

However, handling the 3-letter destination codes and the the "line" destinations requires us to do some pre-processing on the data: we need to find the actual destination names corresponding to the input.

As I go deeper and deeper into this, I discover more and more wrinkles. As I work towards processing this destination data, I realize that I need to consider the three destinations "separately", and so it makes sense to add an additional parameter to both filter and transformOne to specify that I want to deal with each destination index in turn, and then I need to merge these answers together to end up with the array I originally thought of. So much code, so little time.

The tests come out looking like this:
  public function test_firswood_has_two_trams_to_Victoria() {
    $transformer = new Transformer(["from" => ["FIR"]]);
    $out = $transformer->transform([[
      "TLAREF" => "FIR",
      "Dest0" => "Victoria",
      "Wait0" => "6",
      "Dest2" => "Victoria",
      "Wait2" => "18",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ]]);
    $this->assertEquals(["FIR" => ["VIC" => [ "19:58", "20:10" ]]], $out);
  }

  public function test_tram_to_Altrincham_passes_with_empty_filter_in_Dest0() {
    $transformer = new Transformer([]);
    $matches = $transformer->filter([
      "TLAREF" => "SPS",
      "Dest0" => "Altrincham",
      "Wait0" => "6",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ], 0);
    $this->assertTrue($matches);
  }

  public function test_to_Altrincham_passes_filter_in_Dest0() {
    $transformer = new Transformer(["to" => ["Altrincham"]]);
    $matches = $transformer->filter([
      "TLAREF" => "SPS",
      "Dest0" => "Altrincham",
      "Wait0" => "6",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ], 0);
    $this->assertTrue($matches);
  }}
and they pass with this implementation, calling filter, transformOne and merge repeatedly:
    foreach ($odata as $item) {
    for ($i=0;$i<3;$i++) {
      if ($this->filter($item, $i))
      $ret = $this->merge($ret, $this->transformOne($item, $i));
    }
and the filter implementation itself:
  function filter($item, $dst) {
    // This tram must be going *somewhere* (a lot of them are blank)
    $d = "Dest{$dst}";
    if (!array_key_exists($d, $item) || $item[$d] == "") return false;

    // Apply the destination filters
    if ($this->to) {
    if ($item[$d] != $this->to[0]) return false;
    }

    // Apply the origin filters
    if ($this->from) {
    if (!array_key_exists("TLAREF", $item)) return false;
    if ($item["TLAREF"] != "FIR") return false;
    }

    // If it didn't fail, it can be included
    return true;
  }

  function transformOne($pid, $dst) {
    if (array_key_exists("TLAREF", $pid)) {
    return [ "FIR" => [ "VIC" => [ $dst == 0 ? "19:58" : "20:10" ]]];
    }
  }

  function merge($ret, $incl) {
    foreach ($incl as $k => $v) {
    if (!array_key_exists($k, $ret)) {
      $ret[$k] = $v;
    } else {
      foreach ($v as $k2 => $v2) {
      foreach ($v2 as $time) {
        $ret[$k][$k2][] = $time;
      }
      }
    }
    }
    return $ret;
  }
  }
?>
(see METROLINK_PHP_FIRST_FILTERS).

Pre-processing the data

In order to handle the pre-processing, I am adding an extra "top-level" analysis step in metrolink-data.php to tell the transformer to analyze all the input data. Apart from anything else, this makes it easier to unit test.

I can then fairly easily write the code that handles the conversion of 3-letter codes to full names:

Testing this with:
  public function test_tla_collection() {
    $transformer = new Transformer(["to" => ["ALT"]]);
    $tlas = $transformer->collectTLAs([[
      "TLAREF" => "ALT",
      "StationLocation" => "Altrincham"
    ],[
      "TLAREF" => "SPS",
      "StationLocation" => "St Peter's Square"
    ],[
      "TLAREF" => "ALT",
      "StationLocation" => "Altrincham"
    ],[
      "TLAREF" => "WST",
      "StationLocation" => "Weaste"
    ]]);
    $this->assertEquals(["ALT" => "Altrincham", "SPS" => "St Peter's Square", "WST" => "Weaste"], $tlas);
  }

  public function test_analysis_can_turn_ALT_to_Altrincham() {
    $transformer = new Transformer(["to" => ["ALT"]]);
    $transformer->analyze([[
      "TLAREF" => "ALT",
      "StationLocation" => "Altrincham"
    ]]);
    $this->assertEquals(["Altrincham"], $transformer->to);
  }
we can write:
  function analyze($odata) {
    $tlas = $this->collectTLAs($odata);
    $input = $this->to;
    $this->to = [];
    foreach ($input as $dest) {
    $this->analyzeOne($tlas, $dest);
    }
  }

  function collectTLAs($odata) {
    $tlas = [];
    foreach ($odata as $pid) {
    // Obviously we need to have a TLAREF and a StationLocation
    if (!array_key_exists("TLAREF", $pid)) continue;
    if (!array_key_exists("StationLocation", $pid)) continue;

    // Once it's done, don't do it again
    if (array_key_exists($pid["TLAREF"], $tlas)) continue;

    // map it
    $tlas[$pid["TLAREF"]] = $pid["StationLocation"];
    }
    return $tlas;
  }

  function analyzeOne($tlas, $dest) {
    if (array_key_exists($dest, $tlas))
    $this->to[] = $tlas[$dest];
    else
    $this->to[] = $dest;
  }
and wire this into $metrolink-data.php" as:
  $data = json_decode($response);
  $transformer->analyze($data->value);
  $transformed = $transformer->transform($data->value);
  print json_encode($transformed);
and check in as METROLINK_PHP_STATION_ANALYSIS.

Processing for "Routes"

The "routes" feature is slightly more complicated because each input can translate into multiple outputs, but otherwise the principle is the same: we look at each entry in the input and assemble a set of "line names" consisting of the direction and line, and then map that to an array of full station names from the StationLocation field. It's also important to note that any given station is not on one exclusive "line" in this sense; in general, it will be on at least two: one "Incoming" and one "Outgoing".

Again, the tests are like this:
  public function test_line_collection() {
    $transformer = new Transformer([]);
    $lines = $transformer->collectLines([[
      "StationLocation" => "Firswood",
      "Line" => "South Manchester",
      "Direction" => "Outgoing",
    ],[
      "StationLocation" => "Firswood",
      "Line" => "South Manchester",
      "Direction" => "Incoming",
    ],[
      "StationLocation" => "Central Park",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Failsworth",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Freehold",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Weaste",
      "Direction" => "Incoming",
      "Line" => "Eccles",
    ]]);
    $this->assertEquals([
      "O-South Manchester" => ["Firswood"],
      "I-South Manchester" => ["Firswood"],
      "I-Eccles" => ["Weaste"],
      "O-Oldham & Rochdale" => ["Central Park", "Failsworth", "Freehold"]
    ], $lines);
  }

  public function test_analysis_will_expand_outbound_oldham() {
    $transformer = new Transformer(["to" => ["O-Oldham & Rochdale"]]);
    $transformer->analyze([[
      "StationLocation" => "Firswood",
      "Line" => "South Manchester",
      "Direction" => "Outgoing",
    ],[
      "StationLocation" => "Firswood",
      "Line" => "South Manchester",
      "Direction" => "Incoming",
    ],[
      "StationLocation" => "Central Park",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Failsworth",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Freehold",
      "Direction" => "Outgoing",
      "Line" => "Oldham & Rochdale",
    ],[
      "StationLocation" => "Weaste",
      "Direction" => "Incoming",
      "Line" => "Eccles",
    ]]);
    $this->assertEquals(["Central Park", "Failsworth", "Freehold"], $transformer->to);
  }
and the code looks like this:
  function collectLines($odata) {
    $lines = [];
    foreach ($odata as $pid) {
    // Obviously we need the things we want to map
    if (!array_key_exists("Line", $pid)) continue;
    if (!array_key_exists("Direction", $pid)) continue;
    if (!array_key_exists("StationLocation", $pid)) continue;

    $lineName = substr($pid["Direction"], 0, 1) . "-" . $pid["Line"];
    $lines[$lineName][] = $pid["StationLocation"];
    }
    return $lines;
  }
(along with some associated changes to make everything work), which is checked in as METROLINK_PHP_LINE_NOTATION.

Transforming

Up until now, we have just been hacking in the transformed data. Now it's time to actually calculate this. There are two separate parts to this: figuring out the station names; and figuring out the expected arrival times. I have to admit, up until now I have been using the 3-letter codes, but I want to return the actual station names, so some re-work will be necessary. But other than that, getting the correct station names is not hard. Figuring out the arrival time is not hard, per se, but it is, shall we say, complicated. Or possessed of a number of cases and steps.

The time comes from two factors, one is the number of minutes to wait, in the Wait0 or equivalent field; and then other is the LastUpdated field, which is a timestamp. Basically, we need to add the number of minutes to the timestamp. What we decide to do here basically depends on how easy it is to parse and reformat times: if it's easy, we'll use the date logic; if it's not, we'll use an icky and hacky string manipulation. A little experimentation shows that a combination of strtotime and date makes the whole thing very easy, so a few unit tests later we have the ability to format the time, and can include that in the transformOne function.
  public function test_adding_0_minutes_is_easy() {
    $transformer = new Transformer([]);
    $when = $transformer->date("2024-09-17T19:52:55Z", 0);
    $this->assertEquals("19:52", $when);
  }

  public function test_adding_5_minutes() {
    $transformer = new Transformer([]);
    $when = $transformer->date("2024-09-17T19:52:55Z", 5);
    $this->assertEquals("19:57", $when);
  }

  public function test_adding_18_minutes_will_wrap() {
    $transformer = new Transformer([]);
    $when = $transformer->date("2024-09-17T19:52:55Z", 18);
    $this->assertEquals("20:10", $when);
  }

  public function test_can_wrap_to_tomorrow() {
    $transformer = new Transformer([]);
    $when = $transformer->date("2024-09-17T23:55:12Z", 6);
    $this->assertEquals("00:01", $when);
  }
And the corresponding code is:
  function transformOne($pid, $dst) {
    if (array_key_exists("TLAREF", $pid)) {
    return [ "FIR" => [ "VIC" => [ $this->date($pid["LastUpdated"], $pid["Wait{$dst}"]) ]]];
    }
  }

  function date($from, $wait) {
    $unix = strtotime($from);
    $when = $unix + $wait * 60;
    $ret = date('H:i', $when);
    return $ret;
  }

All this is checked in with the tag METROLINK_PHP_EXPECTED_TIME.

And so, I can finally finish it all off by implementing the rest of the transformation code (including the re-work on the station names).

The new test looks like this:
  public function test_a_tram_from_Freehold_to_Shaw_is_correctly_reported() {
    $transformer = new Transformer([]);
    $route = $transformer->transformOne([
      "StationLocation" => "Freehold",
      "Dest0" => "Shaw and Crompton",
      "Wait0" => "4",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ], "0");
    $this->assertEquals(["Freehold" => [ "Shaw and Crompton" => [ "19:56"]]], $route);
  }
(and there are a couple of amendments to other tests to include the StationLocation and update the expectation to be the station name); and the code looks like this:
  function transformOne($pid, $dst) {
    $from = $pid["StationLocation"];
    $to = $pid["Dest{$dst}"];
    $now = $pid["LastUpdated"];
    $wait = $pid["Wait{$dst}"];
    return [ $from => [ $to => [ $this->date($now, $wait) ]]];
  }
And I have wrapped up by checking this in as METROLINK_PHP_FINISH_TRANSFORM.

In the Real World

It almost invariably happens that once I have all my unit tests working, I go to try my code "in the real world" and it just doesn't work. The same is true here. So let's fix the bugs.

First off, it simply doesn't run and I see an error:
$ php-cgi -f metrolink-data.php from[]=FIR from[]=VIC
PHP Fatal error: Uncaught TypeError: arraykeyexists(): Argument #2 ($array) must be of type array, stdClass given in /home/gareth/Projects/IgnoranceBlog/metrolink-php/php/transform.php:25
I've seen this before, so I know what to do. When you decode JSON in PHP, you can either end up with "objects" or you can end up with arrays. I've been thinking about arrays, and testing with those, but I haven't called json_decode in the main code to obtain arrays, so I need to do that. Of course, then that gives me the reverse problem because I have use the object syntax to extract the value from the top-level object. Fixing both of these in one go addresses those issues:
  $data = json_decode($response, true);
  $transformer->analyze($data["value"]);
And the code appears to work:
$ php-cgi -f metrolink-data.php from[]=FIR from[]=VIC
{"Firswood":{"Manchester Airport":["10:29","10:43"],"East Didsbury":["10:40"],"Rochdale Town Centre":["10:32","10:45"],"Victoria":["10:40"]}}
While this may not be obviously wrong where you are, I can see that it's 09:29 here, and these trams all seem to be running an hour in the future. They're not, of course, it's just that something in this code hasn't accounted for GMT/BST. Specifically, I don't even think it's in my code. Looking at the LastUpdated timestamp, it claims to be UTC (the Z on the end), but is in fact in BST. I knew my date code processing had been too easy.

Time for a horrible hack. I want to be quite clear that I am blaming this hack on the fact that the data I am receiving is just wrong. This is not me being lazy. I simply cannot handle the fact that it is giving me a BST date and claiming it is UTC. So I need to say that I am just working in UTC so that the date function gives me back what I'm putting in. I am, if you will, perpetuating the lie.
  require('metrolink_key.php');
  require('transform.php');

  date_default_timezone_set('UTC');
And now it works (yes, 15 minutes have gone by ...)
$ php-cgi -f metrolink-data.php from[]=FIR from[]=VIC
{"Firswood":{"Manchester Airport":["09:59"],"East Didsbury":["09:59","10:10"],"Rochdale Town Centre":["09:45","10:00"],"Victoria":["09:55"]}}

And one more bug

It turns out that I never correctly tested (or implemented) the "from" filter, so I only ever see trams leaving Firswood. Now I look at it, that was obvious from my experiments in the previous section, but I was distracted by the times all being so wrong. This is why real world, end-to-end testing is so important!

OK, let's fix that one too, shall we? It's quite simple to write both a passing and a failing test:
  public function test_from_Firswood_passes_filter_in_Dest0() {
    $transformer = new Transformer(["from" => ["FIR"]]);
    $matches = $transformer->filter([
      "TLAREF" => "FIR",
      "Dest0" => "Victoria",
      "Wait0" => "6",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ], 0);
    $this->assertTrue($matches);
  }

  public function test_from_Victoria_passes_filter_in_Dest0() {
    $transformer = new Transformer(["from" => ["VIC"]]);
    $matches = $transformer->filter([
      "TLAREF" => "VIC",
      "Dest0" => "Altrincham",
      "Wait0" => "6",
      "LastUpdated" => "2024-09-17T19:52:55Z"
    ], 0);
    $this->assertTrue($matches);
  }
And then we can change the filter implementation:
    // Apply the origin filters
    if ($this->from) {
    if (!array_key_exists("TLAREF", $item)) return false;
    $isFrom = $item["TLAREF"];
    $anyFrom = false;
    foreach ($this->from as $f) {
      if ($isFrom == $f)
      $anyFrom = true;
    }
    if (!$anyFrom) return $anyFrom;
    }

And now let's see if we can get data starting at Victoria:
$ php-cgi -f metrolink-data.php from[]=VIC
{"Victoria":{"East Didsbury":["09:53","10:06"],"Piccadilly":["10:04"],"Manchester Airport":["09:55","10:10"],"Bury":["09:55","10:10"],"Rochdale Town Centre":["10:04"]}}
Yes, that's better.