Jul 24 2008
Bus tracker user application (Haskell)
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.
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:
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
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 tagsoupimport Text.HTML.TagSoup
- Bindings to the excellent
libcurlcabal install curlimport Network.Curl
- Parse Date
cabal install parsedateimport System.Time.Parse
I hope all this is of help to other people!
ZOMG I work with Olly, Edinburgh is too small!
I thought I recognised the name … I must have seen it in some Facebook photos or something.