321 lines
8.3 KiB
Go
321 lines
8.3 KiB
Go
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
|
|
}
|