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) }