purge history since March 4th 2018
This commit is contained in:
commit
be4c60f89b
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
pm5conv
|
||||
pm5conv.exe
|
||||
.gitdist
|
16
License
Normal file
16
License
Normal file
|
@ -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/>.
|
37
Readme.md
Normal file
37
Readme.md
Normal file
|
@ -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
|
||||
```
|
28
cmd/pm5conv.go
Normal file
28
cmd/pm5conv.go
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
131
dataformat
Normal file
131
dataformat
Normal file
|
@ -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 |
|
320
pm5conv.go
Normal file
320
pm5conv.go
Normal file
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user