Files
rideaware-api/internal/export/fit_encoder.go
2026-05-17 20:39:47 -05:00

191 lines
5.3 KiB
Go

package export
import (
"bytes"
"fmt"
"time"
"github.com/muktihari/fit/encoder"
"github.com/muktihari/fit/profile/filedef"
"github.com/muktihari/fit/profile/mesgdef"
"github.com/muktihari/fit/profile/typedef"
"rideaware/internal/workout"
)
// EncodeFITWorkout generates a FIT workout file from a Workout's structured segments.
// userFTP is the user's Functional Threshold Power in watts, used to convert
// %FTP power targets into absolute watt values for the device.
func EncodeFITWorkout(w *workout.Workout, userFTP int) ([]byte, error) {
if len(w.WorkoutData.Segments) == 0 {
return nil, fmt.Errorf("workout has no segments")
}
if userFTP <= 0 {
return nil, fmt.Errorf("user FTP must be greater than 0")
}
wktFile := filedef.NewWorkout()
wktFile.FileId.
SetType(typedef.FileWorkout).
SetManufacturer(typedef.ManufacturerDevelopment).
SetProduct(0).
SetTimeCreated(time.Now()).
SetSerialNumber(uint32(time.Now().Unix()))
name := w.WorkoutData.Name
if name == "" {
name = w.Title
}
// Truncate name if too long (FIT spec supports up to 50 chars)
if len(name) > 50 {
name = name[:50]
}
wktFile.Workout = mesgdef.NewWorkout(nil).
SetWktName(name).
SetSport(typedef.SportCycling).
SetSubSport(typedef.SubSportGeneric).
SetNumValidSteps(uint16(len(w.WorkoutData.Segments)))
steps := make([]*mesgdef.WorkoutStep, 0, len(w.WorkoutData.Segments))
for i, seg := range w.WorkoutData.Segments {
step := segmentToFITStep(seg, uint16(i), userFTP)
steps = append(steps, step)
}
wktFile.WorkoutSteps = steps
fit := wktFile.ToFIT(nil)
var buf bytes.Buffer
enc := encoder.New(&buf)
if err := enc.Encode(&fit); err != nil {
return nil, fmt.Errorf("failed to encode FIT workout: %w", err)
}
return buf.Bytes(), nil
}
func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *mesgdef.WorkoutStep {
step := mesgdef.NewWorkoutStep(nil).
SetMessageIndex(typedef.MessageIndex(index)).
SetDurationType(typedef.WktStepDurationTime).
SetDurationValue(uint32(seg.Duration) * 1000) // seconds to milliseconds
switch seg.Type {
case "warmup":
step.SetIntensity(typedef.IntensityWarmup)
setPowerTarget(step, seg, userFTP)
step.SetWktStepName("Warm Up")
case "steadystate":
step.SetIntensity(typedef.IntensityActive)
setPowerTargetSteady(step, seg, userFTP)
step.SetWktStepName("Steady State")
case "interval":
step.SetIntensity(typedef.IntensityInterval)
setPowerTarget(step, seg, userFTP)
step.SetWktStepName("Interval")
case "cooldown":
step.SetIntensity(typedef.IntensityCooldown)
setPowerTarget(step, seg, userFTP)
step.SetWktStepName("Cool Down")
case "ramp":
step.SetIntensity(typedef.IntensityActive)
setPowerTarget(step, seg, userFTP)
step.SetWktStepName("Ramp")
case "freeride":
step.SetIntensity(typedef.IntensityActive)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Free Ride")
case "rest":
step.SetIntensity(typedef.IntensityRest)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Rest")
default:
step.SetIntensity(typedef.IntensityActive)
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
step.SetWktStepName("Active")
}
return step
}
// setPowerTarget sets a power range target using PowerLow/PowerHigh.
// Values are converted from %FTP fractions to absolute watts with +1000 offset.
func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
if seg.PowerLow == 0 && seg.PowerHigh == 0 && seg.Power == 0 {
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
return
}
step.SetTargetType(typedef.WktStepTargetPower)
step.SetTargetValue(0) // 0 = custom range
if seg.PowerLow != 0 || seg.PowerHigh != 0 {
lowWatts := uint32(float64(userFTP) * seg.PowerLow)
highWatts := uint32(float64(userFTP) * seg.PowerHigh)
// Ensure we have a valid range (low < high)
if lowWatts == highWatts {
// Add a small tolerance range (±5W) around the target
if lowWatts < 5 {
lowWatts = 1
highWatts = lowWatts + 5
} else {
lowWatts = lowWatts - 5
highWatts = highWatts + 5
}
}
step.SetCustomTargetValueLow(lowWatts + 1000)
step.SetCustomTargetValueHigh(highWatts + 1000)
} else if seg.Power != 0 {
watts := uint32(float64(userFTP) * seg.Power)
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
lowOffset := uint32(10)
if watts < 10 {
lowOffset = watts - 1
if lowOffset == 0 {
lowOffset = 0
}
}
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
step.SetCustomTargetValueHigh(watts + 10 + 1000)
}
}
// setPowerTargetSteady sets a power target for steady state segments using the Power field.
func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
if seg.Power == 0 {
step.SetTargetType(typedef.WktStepTargetOpen)
step.SetTargetValue(0)
return
}
step.SetTargetType(typedef.WktStepTargetPower)
step.SetTargetValue(0)
watts := uint32(float64(userFTP) * seg.Power)
// Ensure low value doesn't go below 1000 (minimum is 1 watt)
lowOffset := uint32(10)
if watts < 10 {
lowOffset = watts - 1
if lowOffset == 0 {
lowOffset = 0
}
}
step.SetCustomTargetValueLow(watts - lowOffset + 1000)
step.SetCustomTargetValueHigh(watts + 10 + 1000)
}