Hands on

Tracking Device Location with Golang, WLAN Access Points, and the HERE Positioning API

By Nic Raboy | 29 November 2018

When most people, including myself, think of positioning, the first thing that comes to mind is GPS. However, did you know that you can calculate position by using cellular data or by triangulating WLAN access points?

Over the past few weeks I’ve written about tracking position in a web browser as well as tracking position with complex shell scripts. What if we wanted to track position with a popular programming technology using WLAN access points?

We’re going to see how to use Golang to leverage operating system applications to find nearby WLAN access points and convert the data to latitude and longitude positions using the HERE Positioning API.

Understanding the HERE Positioning API

Before we start developing an application with the Go programming language, we need to take a step back to figure out how the HERE Positioning API works. Given cellular information, or WLAN information, or both, we can calculate a location. Cellular information isn’t typically available to us unless we’re on a mobile phone or have a SIM enabled device, so we’re going to rely strictly on WLAN information. When I say WLAN, I’m referring to router information, not the chipsets that might exist within your scanning device.

Per the HERE documentation, we can execute a command like the following:

curl -X POST \
    'https://pos.api.here.com/positioning/v1/locate?app_id=APP-ID-HERE&app_code=APP-CODE-HERE' \
    -H 'Content-Type: application/json' \
    -d '{
        "wlan": [
            {"mac": "88-e9-fe-79-be-20"},
            {"mac": "8C-1A-BF-20-66-AD"},
            {"mac": "A0-E4-53-E9-66-A7"},
            {"mac": "AC-4B-C8-34-F7-01"},
            {"mac": "A0-21-95-57-79-06"},
            {"mac": "00-18-56-51-54-FB"},
            {"mac": "10-30-47-D2-54-55"},
            {"mac": "B8-6B-23-09-87-B1"},
            {"mac": "F4-55-95-11-2C-C1"}
        ]
    }
'

Using X number of hardware addresses to nearby access points, we can make a request to the HERE API. Don’t forget to swap the app_id and app_code values with those found in your HERE Developer Dashboard. Optionally, if we had a power level to each access point, we could include that in the request. The power level can act as a good indicator for distance from each access point, giving us a more reliable estimate in position.

The response to the above command might look something like this:

{
    "location": {
        "lat": 52.5185858,
        "lng": 13.37622638,
        "accuracy": 175
    }
}

Now that we know the requirements for this example, we can work towards gathering the data we need to be successful with tracking our location.

Scanning for WLAN Access Points with Mac and Linux

To scan for nearby WLAN access points in Golang, we have pretty much just one option. While we could put a lot of energy into making our own Go package for the job, the recommended solution online is to leverage operating system software through Go.

So how do we use the available operating system software? Before we focus on Go, let’s take a look at what is available to us.

On Mac, you might be familiar with the following command:

/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport -s

The above command will scan for all access points within proximity using the airport module on Mac. It is what I had demonstrated in my previous example using the command line.

Similarly, if you’re using Linux, you can execute the following:

iwlist wlan0 scan

For both commands, the results will be very different in format, even though they contain the same data. The bulk of our work in Go will be to parse the data and make sense of it.

While I’m sure Windows has a similar command, I only have access to Mac and Linux, with pretty much no familiarity of Windows. If you’re aware of the proper command, we can include it in the Golang application to come.

Developing a Location Tracker with the Go Programming Language

Now that we have a general idea behind scanning for networks and using the HERE Positioning API, we can put our knowledge to good use within a Go application. Assuming you have Go installed and configured, create a main.go file somewhere within your $GOPATH.

Open the main.go file and start by including the following boilerplate code:

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io/ioutil"
    "net/http"
    "net/url"
    "os/exec"
    "regexp"
    "runtime"
    "strconv"
    "strings"
)

type HerePlatform struct {
    AppId   string `json:"app_id"`
    AppCode string `json:"app_code"`
}

func main() {
    fmt.Println("Starting the application...")
    platform := HerePlatform{AppId: "APP-ID-HERE", AppCode: "APP-CODE-HERE"}
}

As you can see, we’ve just created a native structure for holding our app_id and app_code values found in the HERE Developer Dashboard. Because we anticipate making HTTP requests and working with different data requirements, it makes sense to create a few other native data structures.

Based on our short exploration of the HERE Positioning API, we know that it is going to expect hardware addresses as well as power levels. With that in mind, we can create the following:

type WlanInfo struct {
    BSSID string `json:"mac"`
    Power int    `json:"powrx,omitempty"`
}

However, when we get ready to send the data, the format expectation is slightly different. We need to create an array of access points and that can be done with the following:

type AccessPoints struct {
    Wlan []WlanInfo `json:"wlan"`
}

The important part of all the native data structures so far and those to come are the JSON annotations. The JSON annotations will map our request payloads to the expectation as well as map the response payloads to something we can work with.

In our previous exploration, we also saw the response format of the HERE Positioning API. With that information in mind, we can create the following:

type PositionResponse struct {
    Location struct {
        Latitude  float64 `json:"lat"`
        Longitude float64 `json:"lng"`
        Accuracy  int     `json:"accuracy"`
    } `json:"location"`
}

To give this example some extra flair, we’re going to kick it up a notch. Once we have a latitude and longitude position, we’re going to use the HERE Reverse Geocoder API to convert it into an address. We’re going to leverage a previous tutorial I wrote titled, Reverse Geocoding Coordinates to Addresses with the Go Programming Language, but before we do, we need to create something to hold the response.

type GeocoderResponse struct {
    Response struct {
        MetaInfo struct {
            TimeStamp string `json:"TimeStamp"`
        } `json:"MetaInfo"`
        View []struct {
            Result []struct {
                MatchLevel string `json:"MatchLevel"`
                Location   struct {
                    Address struct {
                        Label       string `json:"Label"`
                        Country     string `json:"Country"`
                        State       string `json:"State"`
                        County      string `json:"County"`
                        City        string `json:"City"`
                        District    string `json:"District"`
                        Street      string `json:"Street"`
                        HouseNumber string `json:"HouseNumber"`
                        PostalCode  string `json:"PostalCode"`
                    } `json:"Address"`
                } `json:"Location"`
            } `json:"Result"`
        } `json:"View"`
    } `json:"Response"`
}

If you’re familiar with the HERE Geocoder and HERE Reverse Geocoder APIs, you’ll recognize that the response data structure above is nowhere near complete. However, it does contain the data that we care about, which is address data.

With the data structures designed, we can work on phase one of the application, scanning for WLAN access points. Within the main function, add the following:

var accessPoints AccessPoints
if runtime.GOOS == "darwin" {
    airportCmd := exec.Command("/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport", "-s")
    airportCmdOutput, err := airportCmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }
    lines := strings.Split(string(airportCmdOutput), "\n")
    var wlans []WlanInfo
    for _, line := range lines {
        columns := strings.Fields(line)
        if len(columns) > 0 {
            match, _ := regexp.MatchString("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", columns[1])
            if match == true {
                power, _ := strconv.Atoi(columns[2])
                wlan := WlanInfo{
                    BSSID: columns[1],
                    Power: power,
                }
                wlans = append(wlans, wlan)
            }
        }
    }
    accessPoints = AccessPoints{Wlan: wlans}
} else if runtime.GOOS == "linux" {
    iwlistCmd := exec.Command("iwlist", "wlan0", "scan")
    iwlistCmdOutput, err := iwlistCmd.Output()
    if err != nil {
        fmt.Println(err)
        return
    }
    lines := strings.Split(string(iwlistCmdOutput), "\n")
    var wlans []WlanInfo
    for _, line := range lines {
        columns := strings.Fields(line)
        if len(columns) == 5 {
            match, _ := regexp.MatchString("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", columns[4])
            if match == true {
                wlan := WlanInfo{
                    BSSID: columns[4],
                }
                wlans = append(wlans, wlan)
            }
        }
    }
    accessPoints = AccessPoints{Wlan: wlans}
} else {
    fmt.Println("An unsupported operating system was detected.")
    return
}

The above code could definitely be optimized, but let’s figure out what it is doing.

The first step is to check to see what operating system we’re using. We only plan to support Mac and Linux for this tutorial, but if you’re a Windows guru, you could add support for Windows as well.

Depending on the runtime operating system, we execute the appropriate scan command. In both circumstances, the response to the scan commands will return multiple lines. We need to split these lines and parse each line one at a time.

For each line that we’re working with, we need to split it by whitespace characters. Because we’re going to have a lot of cruft data and not every line will be the same, we need to check to see if a certain column of the line is a hardware address:

match, _ := regexp.MatchString("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$", columns[1])

The column will vary depending on the operating system. To give credit where credit is deserved, the regular expression was taken from Stack Overflow.

Assuming the regular expression was a match for a hardware address, we can add it as a valid WLAN. Your parsing logic for Mac and Linux could probably be much better than mine.

At this point in time, we should have an accessPoints variable full of WLAN access point information. We need to create functions that will make requests against the HERE APIs.

Create a position function like the following:

func (platform *HerePlatform) position(payload []byte) (PositionResponse, error) {
    endpoint, _ := url.Parse("https://pos.api.here.com/positioning/v1/locate")
    queryParams := endpoint.Query()
    queryParams.Set("app_id", platform.AppId)
    queryParams.Set("app_code", platform.AppCode)
    endpoint.RawQuery = queryParams.Encode()
    response, err := http.Post(endpoint.String(), "application/json", bytes.NewBuffer(payload))
    if err != nil {
        return PositionResponse{}, err
    } else {
        data, _ := ioutil.ReadAll(response.Body)
        var positionResponse PositionResponse
        json.Unmarshal(data, &positionResponse)
        return positionResponse, nil
    }
}

The API endpoint expects a POST request, but the function mostly just formats the request. The payload comes from the previously created accessPoints object and the query parameters are formed from the platform information we supplied earlier. If successful, we’ll return a PositionResponse which has our latitude and longitude coordinates.

It also makes sense to create a reverseGeocode function:

func (platform *HerePlatform) reverseGeocode(position PositionResponse) (GeocoderResponse, error) {
    endpoint, _ := url.Parse("https://reverse.geocoder.api.here.com/6.2/reversegeocode.json")
    queryParams := endpoint.Query()
    queryParams.Set("app_id", platform.AppId)
    queryParams.Set("app_code", platform.AppCode)
    queryParams.Set("mode", "retrieveAddresses")
    queryParams.Set("prox", strconv.FormatFloat(position.Location.Latitude, 'f', -1, 64)+","+strconv.FormatFloat(position.Location.Longitude, 'f', -1, 64))
    endpoint.RawQuery = queryParams.Encode()
    response, err := http.Get(endpoint.String())
    if err != nil {
        return GeocoderResponse{}, err
    } else {
        data, _ := ioutil.ReadAll(response.Body)
        var geocoderResponse GeocoderResponse
        json.Unmarshal(data, &geocoderResponse)
        return geocoderResponse, nil
    }
}

The reverseGeocode function will take PositionResponse information to make a request. The response will be of GeocoderResponse format.

With our functions in place, we can make use of them. Towards the end of the main function, we can put the position and reverseGeocode functions to good use:

payload, _ := json.Marshal(accessPoints)
positionResponse, _ := platform.position(payload)
fmt.Println(positionResponse)
geocoderResponse, _ := platform.reverseGeocode(positionResponse)
fmt.Println(geocoderResponse.Response.View[0].Result[0].Location.Address.Label)

Running the application on either Mac or Linux should result in a position being discovered and that position being converted into a nearest possible address.

The more WLAN access points discovered, the more likely and more accurate a position of your device.

Conclusion

You just saw how to build a Go application that scans for WLAN access points on Mac or Linux and determines the the position of the scanning device using the HERE Positioning API. Using the location, we used the HERE Reverse Geocoder API to convert it into an address.

There are other ways to scan for access points on these operating systems and probably better ways to parse the data as well, however, what I’ve done should give you some ideas.

If you’re having trouble cross-compiling your application, check out this previous tutorial I wrote on the subject.