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.

Top comments (0)