├── 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 | Huawei Health activities to GPX 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 | --------------------------------------------------------------------------------