Jul 24 2008

Bus tracker user application (Haskell)

Published by Dougal at 6:38 pm under Programming

Last week I posted about a simple library for talking to the My Bus Tracker website, which gives information about the arrival of buses in Edinburgh. It’s a very neat website that’s horrible to use.

Traffic tracker
Traffic tracker
©

So horrible, in fact, that I’m not the only person to have issues with it, or to try writing a slightly more friendly interface to it. Olly Jackson has started writing a conversion API in PHP, to simplify access to the information. The ideal approach would be for the website to just supply the information in usable format in the first place.

(I noticed the other day that while the display on the site is supposed to look like the real LCD signs, it’s actually not even as good as them. The website gives all destinations in upper case, whereas the physical signs use proper upper and lower case letters.)

Simple timetable queries

It’s time to put together a simple application. We can read some options from disk, some more from the command line, and make some remote queries with the library we wrote earlier.

I’ll just give the edited highlights this time, because there’s no pretty way to do a lot of this stuff — dealing with command line arguments and such — so include only the necessary stuff. There will be lines missing here and there. You can find the code unabridged in the repository.

Preludes and nocturnes

The general process is that we

  • process a config file and then the command line arguments to get our instructions
  • send query to server and wait for result
  • process and print results

I like to create a main loop that describes the overal aim of the program, so that’s what I’ve tried to do here. It should be reasonably obvious where everything happens and what the exit points are.

main = do
    (info,options,query) <- parseArgs =<< parseConfigFile
    unless (null info)
        (mapM_ putStrLn info >> exitWith ExitSuccess)
    if isStopId (queryBusStop query)
        then getBusTimes query >>= printResults options
        else putStrLn "Please enter a valid bus stop ID."

My original version only had two objects returned by the parser chain, options and query. But it struck me that someone looking at the source would have no reason to suspect that the program could not return from the parseArgs function, eg when passing --help as an argument. The actual exiting action was hidden far away from the point that it would take effect. Too sneaky.

I think this way is more honest. If there is any data that needs output straight away (in this case, I mean version information or the help screen) it should be obvious where that happens and what happens next. If there is anything in info when the parser has finished, then we just print that and exit immediately. If not, then we continue with other things.

I’d be interested to hear what other people think about how the entry point of a program should be constructed. For comparison’s sake, this is the Xmonad window manager:

main = do
    args <- getArgs
    let launch = catchIO buildLaunch >> xmonad defaultConfig
    case args of
        []                    -> launch
        ["--resume", _]       -> launch
        ["--help"]            -> usage
        ["--recompile"]       -> recompile True >> return ()
        ["--version"]         -> putStrLn ("xmonad " ++ showVersion version)
        _                     -> fail "unrecognized flags"

This deals with a different case, because there should be at most one command line switch. But it’s still easy to see what will happen in all cases. It’s an elegant tree of options and it’s easy to follow all the branches.

But that’s just an interesting aside. We need to get back to the details. Having looked a bit at how we deal with results of parsed information, let’s take a deeper look at how we get the information in the first place.

A brief diversion into usability

From the outset, we know there is some information we need to get from the user for our program to work. The bare minimum information is a Bus Stop ID; on top of that we can add a Bus Number and a Time. This is what I initially looked for on the command line:

$ buses --stop 1234567 [--bus 25 --time 1710]

The two optional parts in brackets seem very reasonable, but there’s no way I can justify making people remember the ID number for even one bus stop. So we pop it in a file, .bustracker.conf in the user’s home directory, along with a friendlier human name. So we get:

$ cat ~/.bustracker.conf
home 1234567
work 7654321
$ buses --stop home

This is much nicer. We are still left with the problem of finding the Bus Stop IDs for important places (these can be found on the official website or on the stop signs themselves) but this problem is now just a one-off.

Parsing configuration, arguments and options

First we have to load the config file and convert whatever it contains into an association list to pass on to the next stage. I haven’t done any fruity error checking here. I just flat out assume it’ll all work.

parseConfigFile = do
    configfile <- getAppUserDataDirectory "bustracker.conf"
    exists <- doesFileExist configfile
    if exists
        then readFile configfile >>= return . map (mkAssoc . words) . lines
        else return []
  where mkAssoc (a:b:xs) = (a,b)

Parsing command line options is more fiddly, though the strain is eased considerably with the right library.

parseArgs names = do
    args <- replace names `liftM` getArgs
    let parseWith os z = case getOpt Permute os args of
                            (o,_,_) -> foldr id z o
    let options = parseWith argOptions defaultOptArgs
    let query = parseWith queryOptions defaultQuery
    let output = parseWith infoOptions []
    return (output, options, query)

The first thing I do is replace any names on the command line which are also found in the config file, with their associated values.

(I’m going to paste the code for that here because I liked it so much:

replace assocs = map (\l -> maybe l id (lookup l assocs))

I’m pretty sure that can be golfed further but I don’t have a local install of lambdabot so I can’t play pointless games. ;-) But back to the story.)

There really is no short-and-sweet way to do all this argument parsing. This is an example to generate the Query type. Note that it ignores all other options. Everything it doesn’t recognise is discarded.

queryOptions :: [OptDescr (Query -> Query)]
queryOptions =
[ Option "s" ["stop"] (ReqArg stopNum "ID")
    "Stop ID (required)"
, Option "b" ["bus"]  (ReqArg busNum "NUM")
    "Bus number"
, Option "t" ["time"] (ReqArg time "HHmm")
    "Arrival time"
, Option "n" ["now"]  (NoArg (time "now"))
    "Use current time as desired arrival time"
] where busNum s q  | isBusId s = q { queryBusNumber = Just s }
                    | otherwise = q
        stopNum s q | isStopId s = q { queryBusStop = s }
                    | otherwise  = q
        time "now" q = q { queryBusTime = Nothing }
        time hhmm q | isTime hhmm = q { queryBusTime = Just hhmm }
                    | otherwise   = q

This would all be a lot easier if Haskell had better record syntax. It’s a notorious thorn in the side of about a 747’s worth of people. It’s also the one area where the first-class-function ideal breaks down. You can’t, for example map ({ name = "Dougal"}) to change the name field of a listful of records. The braces-syntax is just sugar, alas. This makes everything a lot harder than you think it’ll be; and by extension, more long-winded, uglier and less obvious.

Sorting and pruning results

Given that we have all the options we need and the server responded with some valid information, what do we do with it?

printResults opts rs = mapM_ putStrLn results
    where results = maybe ["No results found."] ppTable rs
          ppTable = map ppResult . trim (maxresults opts) . filter (keep opts)

If nothing came back from the server, we just want to print that fact and be done. A non-empty list needs to be printed line by line. We also make sure to remove results we don’t want (for example, if the user wants to see only buses that can handle wheelchairs) and then limit the number if needed. All these are options that the user can fiddle with on the command line. All this is “pure” code apart from the very last stage when the results are printed to the screen.

Some examples

This is what it looks like in practice:

$ buses --stop home --time 1730 --limit 3
16        COLINTON             *03h 51m
16        HUNTERS TRYST        *04h 01m
22        GYLE CENTRE          *03h 53m

And it also allows for this amusing set of parameters!

$ buses --stop work --now
16    (D) COLINTON              00h 04m
16    (D) HUNTERS TRYST         00h 15m
22    (D) GYLE CENTRE           00h 00m
22    (D) GYLE CENTRE           00h 06m
35    (D) AIRPORT               00h 04m
35    (D) AIRPORT               00h 19m
36    (D) HOLYROOD              00h 16m
36    (D) HOLYROOD              00h 36m
N22       AIRPORT              *10h 21m
N22       AIRPORT              *10h 51m

Stay at home and don’t waste your time

Bus Stop
Bus Stop
© Stefan Jansson

Most of this work would be a lot less pleasant without some of the excellent libraries now available on Hackage. Here are some of the interesting ones I used.

  • Hughes–Peyton Jones Pretty Printer
    • Built-in.
    • import Text.PrettyPrint.HughesPJ
  • TagSoup
    • cabal install tagsoup
    • import Text.HTML.TagSoup
  • Bindings to the excellent libcurl
    • cabal install curl
    • import Network.Curl
  • Parse Date
    • cabal install parsedate
    • import System.Time.Parse

I hope all this is of help to other people!

2 Responses to “Bus tracker user application (Haskell)”

  1. Steveon 24 Jul 2008 at 8:50 pm

    ZOMG I work with Olly, Edinburgh is too small!

  2. Dougalon 24 Jul 2008 at 10:15 pm

    I thought I recognised the name … I must have seen it in some Facebook photos or something.

Trackback URI | Comments RSS

Leave a Reply