purge history since March 4th 2018

This commit is contained in:
gutmet 2019-01-01 19:05:48 +01:00
commit be4c60f89b
7 changed files with 536 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
pm5conv
pm5conv.exe
.gitdist

16
License Normal file
View 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
View 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
View 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
View 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 |

1
go.mod Normal file
View File

@ -0,0 +1 @@
module git.gutmet.org/pm5conv.git

320
pm5conv.go Normal file
View 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
}