aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorgutmet <me.git@gutmet.org>2019-01-01 19:05:48 +0100
committergutmet <me.git@gutmet.org>2019-01-01 19:05:48 +0100
commitbe4c60f89bd0ac4406d1106f7e390cb066d5b2f1 (patch)
tree04b62ea2fd3ddb56140b4b7f81119a61d51fe922
downloadpm5conv-be4c60f89bd0ac4406d1106f7e390cb066d5b2f1.tar.gz
purge history since March 4th 2018
-rw-r--r--.gitignore3
-rw-r--r--License16
-rw-r--r--Readme.md37
-rw-r--r--cmd/pm5conv.go28
-rw-r--r--dataformat131
-rw-r--r--go.mod1
-rw-r--r--pm5conv.go320
7 files changed, 536 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9251eb6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+pm5conv
+pm5conv.exe
+.gitdist
diff --git a/License b/License
new file mode 100644
index 0000000..108c383
--- /dev/null
+++ b/License
@@ -0,0 +1,16 @@
+pm5conv: Converter for logbook data from Concept2 PM5 rowing computers
+Copyright (C) 2018 Alexander Weinhold
+
+Unless explicitly stated otherwise, the following applies:
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License version 2 as published by
+the Free Software Foundation.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <http://www.gnu.org/licenses/>.
diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..af7d7c9
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1,37 @@
+pm5conv
+========
+
+pm5conv converts binary data from Concept2 rowers with PM5 monitor to human readable text.
+
+You can find releases on [releases.gutmet.org](https://releases.gutmet.org) or
+build it yourself.
+
+
+build
+-----
+
+You need to have Go installed. You can then build the executable with 'go build cmd/pm5conv.go'.
+
+
+usage
+-----------
+
+Call pm5conv from the command line, with the path to your logbook directory as parameter. Example:
+
+```
+./pm5conv /media/alexander/myusbstick/Concept2/Logbook
+```
+
+or as Windows user with your USB stick as device 'F:\', execute 'cmd' from the start menu, then:
+
+```
+cd C:\PATH\TO\PM5CONV
+pm5conv F:\Concept2\Logbook
+```
+
+
+You will get JSON output to the command line. To save it to a file, redirect the output with '>', e.g.:
+
+```
+pm5conv F:\Concept2\Logbook > output.txt
+``` \ No newline at end of file
diff --git a/cmd/pm5conv.go b/cmd/pm5conv.go
new file mode 100644
index 0000000..36683ef
--- /dev/null
+++ b/cmd/pm5conv.go
@@ -0,0 +1,28 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "git.gutmet.org/pm5conv.git"
+ "os"
+)
+
+func printUsage() {
+ fmt.Fprintf(os.Stderr, "Usage: %s PATH_TO_LOGBOOK\n", os.Args[0])
+ os.Exit(-1)
+}
+
+func main() {
+ if len(os.Args) != 2 {
+ printUsage()
+ }
+ m, errs := pm5conv.ConvWorkouts(os.Args[1])
+ if len(errs) == 0 {
+ j, _ := json.MarshalIndent(m, "", " ")
+ fmt.Println(string(j))
+ } else {
+ for _, err := range errs {
+ fmt.Fprintln(os.Stderr, err)
+ }
+ }
+}
diff --git a/dataformat b/dataformat
new file mode 100644
index 0000000..8ffc31f
--- /dev/null
+++ b/dataformat
@@ -0,0 +1,131 @@
+The data is split across two files, LogDataAccessTbl.bin and LogDataStorage.bin. The former provides information about the location of the actual workout data in LogDataStorage.
+
+## LogDataAccessTbl.bin
+
+Each entry in **LogDataAccessTbl** is 32 bytes, multibyte entries are Little Endian:
+
+| Byte | Meaning |
+|------:|-------------------------------------|
+| 0 | Magic 0xF0 |
+| 1 | Workout type |
+| 2-3 | Interval rest time* |
+| 4-5 | Workout name* |
+| 6-7 | N/A |
+| 8-9 | Timestamp* |
+| 10-11 | N/A |
+| 12-13 | No. of Splits* |
+| 14-15 | Duration/Distance* |
+| 16-17 | Record offset in LogDataStorage.bin |
+| 18-23 | N/A |
+| 24-25 | Size of record in bytes |
+| 26-27 | Index |
+| 28-31 | N/A |
+
+( * unimportant, because either redundant in actual record or unreliable )
+
+
+### Workout Types
+
+| Value | Type |
+|-------|-------------------|
+| 0x01 | Free Row |
+| 0x03 | Single Distance |
+| 0x05 | Single Time |
+| 0x06 | Timed Interval |
+| 0x07 | Distance Interval |
+| 0x08 | Variable Interval |
+| 0x0A | Single Calorie |
+
+
+## LogDataStorage.bin
+
+Each entry in **LogDataStorage** has a header and a number of splits or intervals. The header size is either 50 bytes (workout types 0x01, 0x03, 0x05, 0x0A) or 52 bytes (others). The size of each split frame is 32 bytes for all workouts except type 0x08, where it is 48 bytes. Multibyte entries are, contrary to LogDataAccessTbl.bin Big Endian(!)
+
+### Header (types 0x01 - 0x05, 0x0A)
+
+| Byte | Meaning |
+|------:|--------------------|
+| 0 | Magic 0x95 |
+| 1 | Type of workout |
+| 2-3 | N/A |
+| 4-7 | Serial number |
+| 8-11 | Timestamp |
+| 12-13 | User ID |
+| 14-17 | N/A |
+| 18 | Record ID |
+| 19-21 | Magic 0x000000 |
+| 22-23 | Total Duration |
+| 24-27 | Total Distance |
+| 28 | SPM |
+| 29 | Split Info |
+| 30-31 | Split Size |
+| 32-49 | N/A |
+
+### Header (Fixed Intervals)
+
+| Byte | Meaning |
+|------:|---------------------|
+| 0-18 | As above |
+| 19 | Number of splits |
+| 20-21 | Split size |
+| 22-23 | Interval rest time |
+| 24-27 | Total Work Duration |
+| 28-29 | Total Rest Distance |
+| 30-51 | N/A |
+
+### Header (Variable Intervals)
+
+| Byte | Meaning |
+|------:|---------------------|
+| 0-18 | As above |
+| 19 | Number of splits |
+| 20-21 | Split size |
+| 20-23 | Total Work Duration |
+| 24-27 | Total Work Distance |
+| 30-51 | N/A |
+
+
+
+### Timestamp format (bytes 8-11)
+
+| Bits | Meaning |
+|------:|------------------|
+| 0-6 | year after 2000 |
+| 7-11 | day |
+| 12-15 | month |
+| 16-23 | hour |
+| 24-31 | minute |
+
+
+### Split Frame (Non-Interval Types)
+
+| Byte | Meaning |
+|------:|-------------------------|
+| 0-1 | Split Duration/Distance |
+| 2 | Heart Rate |
+| 3 | SPM |
+| 4-31 | N/A |
+
+### Interval Frame (Timed Interval, Distance Interval)
+
+| Byte | Meaning |
+|------:|-------------------------|
+| 0-1 | Split Duration/Distance |
+| 2 | Heart Rate |
+| 3 | Rest Heart Rate |
+| 4 | SPM |
+| 5-31 | N/A |
+
+### Variable Interval Frame
+
+| Byte | Meaning |
+|------:|-------------------------|
+| 0 | Split Type |
+| 1 | SPM |
+| 2-5 | Work Interval Time |
+| 6-9 | Work Interval Distance |
+| 10 | Heart Rate |
+| 11 | Rest Heart Rate |
+| 12-13 | Interval Rest Time |
+| 14-15 | Interval Rest Distance |
+| 16-47 | N/A |
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..ef77a1d
--- /dev/null
+++ b/go.mod
@@ -0,0 +1 @@
+module git.gutmet.org/pm5conv.git
diff --git a/pm5conv.go b/pm5conv.go
new file mode 100644
index 0000000..8e7b336
--- /dev/null
+++ b/pm5conv.go
@@ -0,0 +1,320 @@
+package pm5conv
+
+import (
+ "encoding/json"
+ "errors"
+ "io/ioutil"
+ "path/filepath"
+ "time"
+)
+
+func errorMsg(msg string) error {
+ return errors.New("pm5conv: " + msg)
+}
+
+const (
+ metadataFile = "LogDataAccessTbl.bin"
+ storageFile = "LogDataStorage.bin"
+ lenMetadata = 32 // Bytes
+ lenNormalHeader = 50 // Bytes
+ lenIntervalHeader = 52 // Bytes
+ lenSplitFrame = 32 // Bytes
+ lenVariableInterval = 48 // Bytes
+)
+
+const (
+ baseYear = 2000
+)
+
+const (
+ metadataMagic uint8 = 0xf0
+ headerMagic uint8 = 0x95
+)
+
+type WorkoutType uint8
+
+const (
+ FreeRow WorkoutType = 0x01
+ SingleDistance = 0x03
+ SingleTime = 0x05
+ TimedInterval = 0x06
+ DistanceInterval = 0x07
+ VariableInterval = 0x08
+ SingleCalorie = 0x0A
+)
+
+func (t WorkoutType) MarshalJSON() ([]byte, error) {
+ var s string
+ switch t {
+ case FreeRow:
+ s = "Free Row"
+ case SingleDistance:
+ s = "Single Distance"
+ case SingleTime:
+ s = "Single Time"
+ case TimedInterval:
+ s = "Timed Interval"
+ case DistanceInterval:
+ s = "Distance Interval"
+ case VariableInterval:
+ s = "Variable Interval"
+ case SingleCalorie:
+ s = "Single Calorie"
+ default:
+ s = "Unknown"
+ }
+ return json.Marshal(s)
+}
+
+type Workout struct {
+ Index uint16
+ Type WorkoutType
+ Date time.Time
+ UserID uint16
+ IntervalRestTime time.Duration
+ TotalDuration time.Duration
+ TotalDistance uint16
+ TotalRestDistance uint16
+ splitInfo uint8
+ PartDuration time.Duration
+ PartDistance uint16
+ PartCalories uint16
+ Parts []Part
+}
+
+type Part struct {
+ Duration time.Duration
+ Distance uint16
+ Heartrate uint8
+ RestHeartrate uint8
+ SPM uint8
+ SplitType uint8
+ IntervalRestTime time.Duration
+ IntervalRestDistance uint16
+}
+
+func toUint16(high byte, low byte) uint16 {
+ return uint16(high)<<8 | uint16(low)
+}
+
+func duration100ms(duration uint16) time.Duration {
+ return time.Duration(duration) * 100 * time.Millisecond
+}
+
+func dateStructure(data []byte) time.Time {
+ year := 2000 + int((data[0]&0xFE)>>1)
+ day := int((data[0]&0x01)<<4) | int((data[1]&0xF0)>>4)
+ month := int(data[1] & 0x0F)
+ hour := int(data[2])
+ minute := int(data[3])
+ return time.Date(year, time.Month(month), day, hour, minute, 0, 0, time.Local)
+}
+
+func readFixedHeader(w *Workout, header []byte) {
+ w.TotalDuration = duration100ms(toUint16(header[22], header[23]))
+ w.TotalDistance = toUint16(header[26], header[27])
+ w.splitInfo = header[29] >> 4
+ splitSize := toUint16(header[30], header[31])
+ if w.splitInfo == 0 {
+ w.PartDuration = duration100ms(splitSize)
+ } else {
+ if w.Type == SingleCalorie {
+ w.PartCalories = splitSize
+ } else {
+ w.PartDistance = splitSize
+ }
+ }
+}
+
+func readIntervalHeader(w *Workout, header []byte) {
+ distanceDuration := toUint16(header[20], header[21])
+ if w.Type == DistanceInterval {
+ w.PartDistance = distanceDuration
+ } else if w.Type == TimedInterval {
+ w.PartDuration = duration100ms(distanceDuration)
+ }
+ w.IntervalRestTime = duration100ms(toUint16(header[22], header[23]))
+ sumDurationDistance := toUint16(header[26], header[27])
+ if w.Type == DistanceInterval {
+ w.TotalDuration = duration100ms(sumDurationDistance)
+ } else {
+ w.TotalDistance = sumDurationDistance
+ }
+ w.TotalRestDistance = toUint16(header[28], header[29])
+}
+
+func readVariableIntervalHeader(w *Workout, header []byte) {
+ w.TotalDuration = duration100ms(toUint16(header[22], header[23]))
+ w.TotalDistance = toUint16(header[26], header[27])
+}
+
+func readHeader(w *Workout, header []byte) error {
+ if len(header) != lenNormalHeader && len(header) != lenIntervalHeader {
+ return errorMsg("readHeader: programming mistake - not used with header data")
+ }
+ if header[0] != headerMagic {
+ return errorMsg("readHeader: magic byte not found")
+ }
+ w.Date = dateStructure(header[8:12])
+ w.UserID = toUint16(header[12], header[13])
+ if w.Type == DistanceInterval || w.Type == TimedInterval {
+ readIntervalHeader(w, header)
+ } else if w.Type == VariableInterval {
+ readVariableIntervalHeader(w, header)
+ } else {
+ readFixedHeader(w, header)
+ }
+ return nil
+}
+
+func readVariableInterval(data []byte) Part {
+ var v Part
+ v.SplitType = data[0]
+ v.SPM = data[1]
+ v.Duration = duration100ms(toUint16(data[4], data[5]))
+ v.Distance = toUint16(data[8], data[9])
+ v.Heartrate = data[10]
+ v.RestHeartrate = data[11]
+ v.IntervalRestTime = duration100ms(toUint16(data[12], data[13]))
+ v.IntervalRestDistance = toUint16(data[14], data[15])
+ return v
+}
+
+func readVariableIntervals(w *Workout, data []byte) error {
+ if len(data)%lenVariableInterval != 0 {
+ return errorMsg("variable interval data does not match definition")
+ }
+ for i := 0; i < len(data); i = i + lenVariableInterval {
+ part := readVariableInterval(data[i : i+lenVariableInterval])
+ w.Parts = append(w.Parts, part)
+ }
+ return nil
+}
+
+func readInterval(w *Workout, data []byte) Part {
+ var s Part
+ distanceDuration := toUint16(data[0], data[1])
+ if w.Type == DistanceInterval {
+ s.Duration = duration100ms(distanceDuration)
+ } else {
+ s.Distance = distanceDuration
+ }
+ s.Heartrate = data[2]
+ s.RestHeartrate = data[3]
+ s.SPM = data[4]
+ return s
+}
+
+func readSplit(w *Workout, data []byte) Part {
+ var s Part
+ distanceDuration := toUint16(data[0], data[1])
+ if w.splitInfo == 0 {
+ s.Distance = distanceDuration
+ } else {
+ s.Duration = duration100ms(distanceDuration)
+ }
+ s.Heartrate = data[2]
+ s.SPM = data[3]
+ return s
+}
+
+func readSplits(w *Workout, data []byte) error {
+ if len(data)%lenSplitFrame != 0 {
+ return errorMsg("split/interval data does not match definition")
+ }
+ var reader func(*Workout, []byte) Part
+ if w.Type == TimedInterval || w.Type == DistanceInterval {
+ reader = readInterval
+ } else {
+ reader = readSplit
+ }
+ for i := 0; i < len(data); i = i + lenSplitFrame {
+ part := reader(w, data[i:i+lenSplitFrame])
+ w.Parts = append(w.Parts, part)
+ }
+ return nil
+}
+
+func readParts(w *Workout, data []byte) error {
+ if w.Type == VariableInterval {
+ return readVariableIntervals(w, data)
+ } else {
+ return readSplits(w, data)
+ }
+}
+
+func readWorkoutData(w *Workout, data []byte, lenWorkoutHeader int) error {
+ header := data[0:lenWorkoutHeader]
+ err := readHeader(w, header)
+ if err == nil && len(data) > lenWorkoutHeader {
+ err = readParts(w, data[lenWorkoutHeader:])
+ }
+ return err
+}
+
+func checkMetaValidity(meta []byte, allWorkouts []byte) error {
+ if len(meta) != 32 {
+ return errorMsg("fillWorkout: wrong length in metadata")
+ }
+ if meta[0] != metadataMagic {
+ return errorMsg("fillWorkout: magic byte not found")
+ }
+ if len(allWorkouts) == 0 {
+ return errorMsg("fillWorkout: no workout data found")
+ }
+ return nil
+}
+
+func fillWorkout(meta []byte, allWorkouts []byte) (*Workout, error) {
+ err := checkMetaValidity(meta, allWorkouts)
+ if err != nil {
+ return nil, err
+ }
+ w := &Workout{}
+ w.Parts = make([]Part, 0)
+ w.Index = toUint16(meta[27], meta[26])
+ w.Type = WorkoutType(meta[1])
+
+ var lenWorkoutHeader int
+ if w.Type == TimedInterval || w.Type == DistanceInterval {
+ lenWorkoutHeader = lenIntervalHeader
+ } else {
+ lenWorkoutHeader = lenNormalHeader
+ }
+ numSplits := int(toUint16(meta[13], meta[12]))
+ storageOffset := int(toUint16(meta[17], meta[16]))
+ storageSize := int(toUint16(meta[25], meta[24]))
+ storageEnd := storageOffset + storageSize
+ sizePlausible := storageEnd <= len(allWorkouts)
+ sizePlausible = sizePlausible && storageSize >= lenWorkoutHeader
+ sizePlausible = sizePlausible && (storageSize-lenWorkoutHeader == numSplits*lenSplitFrame)
+ if !sizePlausible {
+ return nil, errorMsg("fillWorkout: invalid record size in access table")
+ }
+ readWorkoutData(w, allWorkouts[storageOffset:storageEnd], lenWorkoutHeader)
+ return w, nil
+}
+
+func ConvWorkouts(dirname string) ([]*Workout, []error) {
+ errors := make([]error, 0)
+ metadata := make([]*Workout, 0)
+ meta, err := ioutil.ReadFile(filepath.Join(dirname, metadataFile))
+ var allWorkouts []byte
+ if err == nil {
+ allWorkouts, err = ioutil.ReadFile(filepath.Join(dirname, storageFile))
+ }
+ if err == nil {
+ for i := 0; i < len(meta)/lenMetadata-1; i++ {
+ metaframe := meta[i*lenMetadata : (i+1)*lenMetadata]
+ m, err := fillWorkout(metaframe, allWorkouts)
+ if err != nil {
+ errors = append(errors, err)
+ } else {
+ metadata = append(metadata, m)
+ }
+ }
+ } else {
+ errors = append(errors, errorMsg(err.Error()))
+ }
+ return metadata, errors
+}