├── hitrack2gpx.png
├── scripts
├── test
├── install
├── setup
└── run
├── .gitignore
├── init.go
├── cmd
└── hitrack2gpx
│ └── hitrack2gpx.go
├── LICENSE
├── db.go
├── parser.go
├── README.md
└── gpx.go
/hitrack2gpx.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tommyblue/huawei-health-to-gpx/HEAD/hitrack2gpx.png
--------------------------------------------------------------------------------
/scripts/test:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cd "$(dirname "${0}")/.."
6 |
7 | ENV=test go test ./... -v
8 |
--------------------------------------------------------------------------------
/scripts/install:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cd "$(dirname "${0}")/.."
6 |
7 | cd cmd/hitrack2gpx
8 | go install
9 |
--------------------------------------------------------------------------------
/scripts/setup:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cd "$(dirname "${0}")/.."
6 |
7 | go get github.com/mattn/go-sqlite3
8 |
--------------------------------------------------------------------------------
/scripts/run:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -eu
4 |
5 | cd "$(dirname "${0}")/.."
6 |
7 | if [ $# -lt 1 ]; then
8 | echo -e "Missing db path argument"
9 | exit 1
10 | fi
11 | go run cmd/hitrack2gpx/main.go "$@"
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, build with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | dump/
15 |
--------------------------------------------------------------------------------
/init.go:
--------------------------------------------------------------------------------
1 | package hitrack2gpx
2 |
3 | import (
4 | "log"
5 | "strconv"
6 | )
7 |
8 | type HT2G struct {
9 | DbPath string
10 | FileIndex int
11 | }
12 |
13 | func Init(dbPath string, fileIndex string) *HT2G {
14 | i, err := strconv.Atoi(fileIndex)
15 |
16 | if err != nil {
17 | log.Fatal(err)
18 | }
19 | mainConf := &HT2G{
20 | DbPath: dbPath,
21 | FileIndex: i,
22 | }
23 | return mainConf
24 | }
25 |
--------------------------------------------------------------------------------
/cmd/hitrack2gpx/hitrack2gpx.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "os"
6 |
7 | hitrack2gpx "github.com/tommyblue/huawei-health-to-gpx"
8 | )
9 |
10 | func main() {
11 | if len(os.Args) < 2 {
12 | log.Fatal("Missing DB Path")
13 | }
14 | i := "0"
15 | if len(os.Args) >= 3 {
16 | i = os.Args[2]
17 | }
18 | conf := hitrack2gpx.Init(os.Args[1], i)
19 |
20 | database := hitrack2gpx.GetDb(conf)
21 | defer database.Close()
22 |
23 | trackDump := database.GetTracks(conf.FileIndex)
24 |
25 | if i != "0" {
26 | track := hitrack2gpx.ParseTrackDump(trackDump)
27 |
28 | hitrack2gpx.GPXFromDump(track)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Tommaso Visconti
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/db.go:
--------------------------------------------------------------------------------
1 | package hitrack2gpx
2 |
3 | import (
4 | "bytes"
5 | "database/sql"
6 | "fmt"
7 | "log"
8 |
9 | _ "github.com/mattn/go-sqlite3"
10 | )
11 |
12 | type scannerFn func(dest ...interface{}) error
13 | type callbackFn func(scannerFn)
14 |
15 | type DB struct {
16 | db *sql.DB
17 | }
18 |
19 | func GetDb(conf *HT2G) *DB {
20 | db, err := sql.Open("sqlite3", conf.DbPath)
21 | if err != nil {
22 | log.Fatal(err)
23 | }
24 | return &DB{
25 | db: db,
26 | }
27 | }
28 |
29 | func (db *DB) Close() {
30 | db.db.Close()
31 | }
32 |
33 | func (db *DB) GetTracks(fileIndex int) string {
34 | // var acc []string
35 | files := db.getFiles(fileIndex)
36 | if fileIndex == 0 {
37 | fmt.Println("\nSelect an ID from the list above and pass it as second argument\n")
38 | return ""
39 | }
40 | for _, id := range files {
41 | if id == fileIndex {
42 | return db.getTrack(id)
43 | }
44 | // acc = append(acc, db.getTrack(id))
45 | }
46 | // return acc
47 | log.Fatal("Cannot find the selected ID")
48 | return ""
49 | }
50 |
51 | func (db *DB) getFiles(selectedFileIndex int) []int {
52 | query := `SELECT file_index, file_path FROM apk_file_info WHERE file_path LIKE '%HiTrack%';`
53 |
54 | var acc []int
55 |
56 | callback := func(scanFn scannerFn) {
57 | var fileIndex int
58 | var filePath string
59 | err := scanFn(&fileIndex, &filePath)
60 | if err != nil {
61 | log.Fatal(err)
62 | }
63 | if selectedFileIndex == 0 {
64 | fmt.Printf("File %s (ID %d)\n", filePath, fileIndex)
65 | }
66 | acc = append(acc, fileIndex)
67 | }
68 |
69 | db.makeQuery(query, callback)
70 |
71 | return acc
72 | }
73 |
74 | func (db *DB) getTrack(id int) string {
75 | query := fmt.Sprintf(`SELECT file_data FROM apk_file_data WHERE file_index=%d ORDER BY data_index;`, id)
76 | // lines to be joined. If doesn't end with ; is interrupted?
77 | var b bytes.Buffer
78 | callback := func(scanFn scannerFn) {
79 | var fileData string
80 | err := scanFn(&fileData)
81 | if err != nil {
82 | log.Fatal(err)
83 | }
84 | b.WriteString(fileData)
85 | }
86 |
87 | db.makeQuery(query, callback)
88 | return b.String()
89 | }
90 |
91 | func (db *DB) makeQuery(query string, callback callbackFn) {
92 | rows, err := db.db.Query(query)
93 | if err != nil {
94 | log.Fatal(err)
95 | }
96 | defer rows.Close()
97 | for rows.Next() {
98 | callback(rows.Scan)
99 | }
100 | err = rows.Err()
101 | if err != nil {
102 | log.Fatal(err)
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/parser.go:
--------------------------------------------------------------------------------
1 | package hitrack2gpx
2 |
3 | import (
4 | "bufio"
5 | "log"
6 | "math"
7 | "strconv"
8 | "strings"
9 | "time"
10 | )
11 |
12 | type heartRate struct {
13 | k int
14 | v int
15 | }
16 |
17 | type position struct {
18 | lat float32
19 | lon float32
20 | k int
21 | alt float32
22 | t float32
23 | }
24 |
25 | type TrackLine map[string]string
26 | type TimedLine map[string]TrackLine
27 | type HuaweiTrack map[int]TimedLine
28 |
29 | /*
30 | ParseTrackDump gets a dump as a test, loops over lines and, for each line, identifies the
31 | type and populates a map
32 | */
33 | func ParseTrackDump(trackDump string) *HuaweiTrack {
34 | track := HuaweiTrack{}
35 | scanner := bufio.NewScanner(strings.NewReader(trackDump))
36 | for scanner.Scan() {
37 | timestamp, recordType, payload, err := parseLine(scanner.Text())
38 | if err != nil {
39 | log.Fatal(err)
40 | }
41 | if _, ok := track[timestamp]; !ok {
42 | track[timestamp] = TimedLine{}
43 | }
44 | track[timestamp][recordType] = payload
45 | }
46 |
47 | /* [
48 | s-r (k, v sempre 0) cadence
49 | rs (k, v)
50 | lbs (alt, t, lon, lat, k) location
51 | p-m (k, v) ritmo medio al km (in secondi)
52 | b-p-m (k, v)
53 | h-r (k, v) heart-rate
54 | ] */
55 | return &track
56 | }
57 |
58 | func parseLine(line string) (int, string, TrackLine, error) {
59 | payload := TrackLine{}
60 | var tp string
61 | var timestamp int
62 | var err error
63 | for _, value := range strings.Split(line, ";") {
64 | if value != "" {
65 | r := strings.Split(value, "=")
66 | if r[0] == "tp" {
67 | tp = r[1]
68 | }
69 | v := r[1]
70 | if isTimestamp(r[0], tp) {
71 | v = fixTimestamp(v)
72 | timestamp, err = strconv.Atoi(v)
73 | if err != nil {
74 | return 0, "", TrackLine{}, err
75 | }
76 | }
77 | payload[r[0]] = v
78 | // fmt.Println(r[0])
79 | }
80 | }
81 |
82 | if timestamp == 0 {
83 | return 0, "", TrackLine{}, err
84 | }
85 | return timestamp, tp, payload, nil
86 | }
87 |
88 | func isTimestamp(value, lineType string) bool {
89 | return (lineType == "h-r" && value == "k") || (lineType == "lbs" && value == "t")
90 | }
91 |
92 | // All timestamps must have 9 digits
93 | func fixTimestamp(timestampStr string) string {
94 | f, err := strconv.ParseFloat(timestampStr, 64)
95 |
96 | if err != nil {
97 | log.Fatal(err)
98 | }
99 |
100 | t := int(f)
101 | oom := int(math.Log10(float64(t)))
102 | divisor := 1
103 | if oom > 9 {
104 | divisor = int(math.Pow(10, float64(oom-9)))
105 | } else if oom < 9 {
106 | divisor = int(math.Pow(0.1, float64(9-oom)))
107 | }
108 | t = int(t / divisor)
109 |
110 | return strconv.Itoa(t)
111 | }
112 |
113 | func parseTimestamp(timestamp string) time.Time {
114 | t, err := strconv.ParseInt(timestamp, 10, 64)
115 | if err != nil {
116 | log.Fatal(err)
117 | }
118 | return time.Unix(t, 0)
119 | }
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Huawei Health activities to GPX
4 |
5 | Golang app that generates GPX files from a Huawei HiTrack backup.
6 |
7 | HiTrack data is what Huawei wearables generate after an activity ([Huawei Band 3 PRO](https://consumer.huawei.com/en/wearables/band3-pro/) is an example).
8 |
9 | This app gets as input the HiTrack data from a Huawei Health app backup (as SQLite database) and outputs
10 | GPX files with support for timestamped GPS, altitude, heart-rate, and cadence data where available.
11 |
12 | This app gets inspiration from [Huawei TCX Converter](https://github.com/aricooperdavis/Huawei-TCX-Converter) which should be used if your backup has a different format (see below).
13 |
14 | ## How to get the Huawei Health db
15 |
16 | - Open the Huawei Health app and open the exercise that you want to convert to view it's trajectory. This ensures that its HiTrack file is generated.
17 | - Download the [Huawei Backup App](https://play.google.com/store/apps/details?id=com.huawei.KoBackup&hl=en_GB) onto your phone.
18 | - Start a new **unencrypted** backup of the Huawei Health app data to your external storage (SD Card)
19 | - Connect the phone to the pc using a USB cable. When prompted on the phone, authorize the pc to access data.
20 | - If you can see the phone as an external memory (like on Linux) navigate to `/HuaweiBackup/backupFiles//` and copy `com.huawei.health.db` to your computer. If you can't find the `.db` file but you find the `com.huawei.health.tar` file, than you should use [Huawei TCX Converter](https://github.com/aricooperdavis/Huawei-TCX-Converter).
21 | - If you're on a Mac you need to install the [HiSuite app](https://consumer.huawei.com/en/support/hisuite/) and use it to access the SD card on the phone. Then follow the same instructions as the point above.
22 |
23 | ### Note
24 |
25 | For some reason if your wearable loses the GPS signal at the end of the record, the outputted dump doesn't
26 | contain GPS data, although the Health app can still show the track. This happen at least on the Band 3 Pro.
27 |
28 | The fact that the Health app shows the GPS track let me suspect data is still somewhere, but I didn't
29 | find a way to get it :/
30 |
31 | ## How to build and install
32 |
33 | ```
34 | git clone git@github.com:tommyblue/huawei-health-to-gpx.git
35 | ./scripts/setup
36 | ./scripts/install
37 | ```
38 |
39 | ## How to run the app
40 |
41 | The app can check the db and list the existing tracks:
42 |
43 | ```
44 | hitrack2gpx ~//com.huawei.health.db
45 | ```
46 |
47 | If an activity ID is provided, the app outputs the GPX file:
48 |
49 | ```
50 | hitrack2gpx ~//com.huawei.health.db
51 | ```
52 |
53 | The output can be saved to a file:
54 |
55 | ```
56 | hitrack2gpx ~//com.huawei.health.db > ~//file.gpx
57 | ```
58 |
59 | ## Requirements
60 |
61 | * [go-sqlite3](https://github.com/mattn/go-sqlite3)
62 |
--------------------------------------------------------------------------------
/gpx.go:
--------------------------------------------------------------------------------
1 | package hitrack2gpx
2 |
3 | import (
4 | "os"
5 | "sort"
6 | "time"
7 |
8 | "github.com/beevik/etree"
9 | )
10 |
11 | type point struct {
12 | lat string
13 | lon string
14 | elev string
15 | time string
16 | hr string
17 | cadence string
18 | }
19 |
20 | type GpxFile struct {
21 | document *etree.Document
22 | points []point
23 | }
24 |
25 | func GPXFromDump(dump *HuaweiTrack) *GpxFile {
26 | gpx := &GpxFile{}
27 | gpx.points = populatePoints(dump)
28 | gpx.document = etree.NewDocument()
29 | gpx.initXML()
30 | root := gpx.instrument()
31 | gpx.fillData(root)
32 | gpx.document.Indent(2)
33 | gpx.document.WriteTo(os.Stdout)
34 |
35 | return gpx
36 | }
37 |
38 | func (gpx *GpxFile) initXML() {
39 | gpx.document.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
40 | }
41 |
42 | func (gpx *GpxFile) instrument() *etree.Element {
43 | el := gpx.document.CreateElement("gpx")
44 | el.CreateAttr("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
45 | el.CreateAttr("xmlns:gpxdata", "http://www.cluetrust.com/XML/GPXDATA/1/0")
46 | el.CreateAttr("xmlns", "http://www.topografix.com/GPX/1/0")
47 | el.CreateAttr("xsi:schemaLocation", "http://www.topografix.com/GPX/1/0 http://www.topografix.com/GPX/1/0/gpx.xsd http://www.cluetrust.com/XML/GPXDATA/1/0 http://www.cluetrust.com/Schemas/gpxdata10.xsd")
48 | el.CreateAttr("version", "1.0")
49 | el.CreateAttr("creator", "HT2G")
50 |
51 | return el
52 | }
53 |
54 | func (gpx *GpxFile) fillData(root *etree.Element) {
55 | author := root.CreateElement("author")
56 | author.CreateText("HT2G")
57 |
58 | url := root.CreateElement("url")
59 | url.CreateText("HT2G")
60 |
61 | time := root.CreateElement("time")
62 | time.CreateText("HT2G")
63 |
64 | trk := root.CreateElement("trk")
65 | gpx.fillTrack(trk)
66 | }
67 |
68 | func (gpx *GpxFile) fillTrack(root *etree.Element) {
69 | name := root.CreateElement("name")
70 | name.CreateText("HT2G")
71 |
72 | trkseg := root.CreateElement("trkseg")
73 | gpx.fillSegment(trkseg)
74 | }
75 |
76 | func (gpx *GpxFile) fillSegment(root *etree.Element) {
77 | for _, p := range gpx.points {
78 | gpx.fillPoint(root, p)
79 | }
80 | }
81 |
82 | var previousHr = "0.0"
83 | var previousCadence = "0"
84 |
85 | func (gpx *GpxFile) fillPoint(root *etree.Element, p point) {
86 | trkpt := root.CreateElement("trkpt")
87 | trkpt.CreateAttr("lat", p.lat)
88 | trkpt.CreateAttr("lon", p.lon)
89 |
90 | ele := trkpt.CreateElement("ele")
91 | ele.CreateText(p.elev)
92 |
93 | time := trkpt.CreateElement("time")
94 | time.CreateText(p.time)
95 |
96 | extensions := trkpt.CreateElement("extensions")
97 |
98 | if p.hr == "0.0" || p.hr == "" {
99 | p.hr = previousHr
100 | }
101 | previousHr = p.hr
102 | hr := extensions.CreateElement("gpxdata:hr")
103 | hr.CreateText(previousHr)
104 |
105 | if p.cadence == "" {
106 | p.cadence = previousCadence
107 | }
108 | previousCadence = p.cadence
109 | cadence := extensions.CreateElement("gpxdata:cadence")
110 | cadence.CreateText(previousCadence)
111 | }
112 |
113 | func populatePoints(dump *HuaweiTrack) []point {
114 | var pts []point
115 | for _, timestamp := range getSortedKeys(dump) {
116 | timedLine := (*dump)[timestamp]
117 | var pt point
118 | pt.time = getTimeStr(timestamp)
119 | for k, v := range timedLine {
120 | if k == "lbs" {
121 | pt.lat = v["lat"]
122 | pt.lon = v["lon"]
123 | pt.elev = v["alt"]
124 | } else if k == "h-r" {
125 | pt.hr = v["v"]
126 | }
127 | }
128 | if isValidPoint(pt) {
129 | pts = append(pts, pt)
130 | }
131 | }
132 |
133 | return pts
134 | }
135 |
136 | func isValidPoint(pt point) bool {
137 | // Clear data:
138 | /*
139 | try:
140 | for line in data['hr']:
141 | # Heart-rate is too low/high (type is xsd:unsignedbyte)
142 | if line[5] < 1 or line[5] > 254:
143 | data['hr'].remove(line)
144 |
145 | for line in data['cad']:
146 | # Cadence is too low/high (type is xsd:unsignedbyte)
147 | if line[6] < 0 or line[6] > 254:
148 | data['cad'].remove(line)
149 |
150 | for line in data['alti']:
151 | # Altitude is too low/high (dead sea/everest)
152 | if line[4] < 1000 or line[4] > 10000:
153 | data['alti'].remove(line)
154 | */
155 | if pt.lat == "90.0" || pt.lat == "" || pt.lon == "-80.0" || pt.lon == "" {
156 | return false
157 | }
158 | // hr, err := strconv.Atoi(pt.hr)
159 | // if err != nil || hr < 0 || hr > 254 {
160 | // return false
161 | // }
162 | return true
163 | }
164 |
165 | func getTimeStr(timestamp int) string {
166 | ts := time.Unix(int64(timestamp), 0)
167 | return ts.Format(time.RFC3339)
168 | }
169 |
170 | func getSortedKeys(dump *HuaweiTrack) []int {
171 | keys := []int{}
172 | for k := range *dump {
173 | keys = append(keys, k)
174 | }
175 | sort.Ints(keys)
176 | return keys
177 | }
178 |
--------------------------------------------------------------------------------