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.
| 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()) } }() } } 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) } } 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) } } 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 -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.
