DEV Community

Theo Bodmer
Theo Bodmer

Posted on

Talking to €20 GPS trackers in Go

Many GPS trackers between €15 and €40 speak one of two protocols: GT06 (binary, from Concox) or H02 (plain text, *HQ,…#). The protocol documents are scattered PDFs, often for a slightly different firmware. This post is what we learned running both in production for trackportal.com. The full listener is standard-library Go, with a simulator that replays real tracker sessions:

👉 github.com/Insafety/gt06-h02-go

Want to try your own trackers without writing any code? Get in touch and we'll open a trackportal test account for you.

Simulator (left) replaying GT06 and H02 sessions into the parser (right)

GT06 H02
Encoding Binary, 78 78 … 0d 0a ASCII, *HQ, … #
Device ID Only in the login packet In every message
Server reply Ack with serial + CRC None
Speed km/h knots

The listener

Both run over plain TCP, one port each (5023 and 5024). One goroutine per connection, and one decoder per connection, which matters for GT06:

func listen(addr string, newDecoder func(net.Conn) func([]byte), split bufio.SplitFunc) {
    l, err := net.Listen("tcp", addr)
    if err != nil {
        log.Fatal(err)
    }
    for {
        c, err := l.Accept()
        if err != nil {
            continue
        }
        go func() {
            defer c.Close()
            decode := newDecoder(c) // fresh session state per connection
            s := bufio.NewScanner(c)
            s.Split(split) // whole frames only
            for s.Scan() {
                decode(s.Bytes())
            }
        }()
    }
}
Enter fullscreen mode Exit fullscreen mode

TCP is a stream, not messages: one read can hold two frames, or half of one. The split function cuts whole frames out first, on # for H02 and on the length byte for GT06.

GT06: binary, and the connection is the identity

A GT06 frame is 78 78, a length byte, the protocol number, the content, a 2-byte serial, a CRC and 0d 0a. The device connects and sends a 0x01 login carrying its IMEI, then waits for your ack. Every packet after that arrives without the IMEI: the TCP connection is the only identity. So the decoder is a closure that holds the session:

func gt06(c net.Conn) func([]byte) {
    imei := "" // set by the login, used by every packet after it
    return func(m []byte) {
        switch m[3] {
        case 0x01: // login: IMEI as 8 BCD bytes, first nibble is padding
            imei = hex.EncodeToString(m[4:12])[1:]
        case 0x12: // position
            lat := float64(binary.BigEndian.Uint32(m[11:15])) / 1800000 // minutes × 30000
            lon := float64(binary.BigEndian.Uint32(m[15:19])) / 1800000
            // Course/status word: bit 10 north, bit 11 west, bit 12 GPS fixed.
            cs := binary.BigEndian.Uint16(m[20:22])
            if cs&0x0400 == 0 {
                lat = -lat
            }
            if cs&0x0800 != 0 {
                lon = -lon
            }
            fix := cs&0x1000 != 0
            log.Printf("%s %.6f,%.6f %d km/h fix=%t", imei, lat, lon, m[19], fix)
        }
        // Ack every packet: its protocol number, its own serial, a new CRC.
        s := int(m[2]) - 1 // the serial sits right before the CRC
        r := []byte{0x78, 0x78, 0x05, m[3], m[s], m[s+1], 0, 0, 0x0d, 0x0a}
        binary.BigEndian.PutUint16(r[6:8], crc16X25(r[2:6]))
        c.Write(r)
    }
}
Enter fullscreen mode Exit fullscreen mode

H02: text, stateless

A message looks like *HQ,1234567890,V1,083000,A,4722.6140,N,00832.5020,E,21.60,135,141026,fffbffff#: device ID, type, time, fix flag, latitude, longitude, speed, course, date and a status word. Every message carries the device ID, so there is no session and nothing to ack:

func h02(net.Conn) func([]byte) {
    return func(m []byte) { // no state: every message names its device
        f := strings.Split(strings.TrimRight(string(m), "#\r\n"), ",")
        if len(f) < 13 || f[0] != "*HQ" {
            return
        }
        t, _ := time.Parse("020106150405", f[11]+f[3]) // ddmmyy + hhmmss, UTC
        lat, lon := dm(f[5], 2), dm(f[7], 3)           // NMEA ddmm.mmmm, dddmm.mmmm
        if f[6] == "S" {
            lat = -lat
        }
        if f[8] == "W" {
            lon = -lon
        }
        knots, _ := strconv.ParseFloat(f[9], 64)
        st, _ := strconv.ParseUint(f[12], 16, 32) // active-low: 0 = true
        sos, ign := st&(1<<1) == 0, st&(1<<18) == 0
        log.Printf("%s %s %.6f,%.6f %.0f km/h fix=%t ign=%t sos=%t",
            f[1], t, lat, lon, knots*1.852, f[4] == "A", ign, sos)
    }
}
Enter fullscreen mode Exit fullscreen mode

Both helpers, crc16X25 and dm, are in the repo's parser/main.go.

Testing without a car

The repo's simulator replays 15 recorded sessions from five real trackers against your listener:

go run ./parser                                  # terminal 1
go run ./tracker_simulator                       # terminal 2
go run ./tracker_simulator -delay 200ms -split   # every frame in two halves
Enter fullscreen mode Exit fullscreen mode

-split and -merge reproduce what mobile networks do to TCP, so you can check your framing holds up.

Found a device that behaves differently, or a trap we missed? Open an issue, we'd like to hear about it.


Theo Bodmer is CTO of Insafety GmbH in Zürich, which builds trackportal: fleet tracking for GT06, H02 and other trackers, with the option to run the backend in your own Google Cloud project.

Top comments (0)