diff --git a/docs/usage.md b/docs/usage.md index df978051..59b470b8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -104,7 +104,9 @@ func main() { // The listener will receive every decoded message from the decoder as soon as it is decoded, // The Activity Listener will transform the messages into an Activity File. - al := filedef.NewListener(filedef.NewActivity()) + al := filedef.NewListener[filedef.Activity]() + defer al.Close() // release channel used by listener + dec := decoder.New(bufio.NewReader(f), decoder.WithMesgListener(al), // Add activity listener to the decoder decoder.WithBroadcastOnly(), // Direct the decoder to only broadcast the messages without retaining them. @@ -171,7 +173,9 @@ func main() { } defer f.Close() - al := filedef.NewListener(filedef.NewActivity()) + al := filedef.NewListener[filedef.Activity]() + defer al.Close() // release channel used by listener + dec := decoder.New(bufio.NewReader(f), decoder.WithMesgListener(al), decoder.WithBroadcastOnly(), @@ -188,7 +192,6 @@ func main() { // File Type: activity if fileId.Type != typedef.FileActivity { - _ = al.File() // Note: It is recommended to call this method to release the listener's channel. return // Let's stop. } @@ -347,7 +350,7 @@ func main() { ### Encode Common File Types -Currently, only Activity Files are supported. Please note that this feature is still under development and has not been tested yet. +Please note that this feature is still under development and has not been fully tested yet. ```go ... diff --git a/profile/filedef/activity.go b/profile/filedef/activity.go index 0eccf5b4..772eb1fc 100644 --- a/profile/filedef/activity.go +++ b/profile/filedef/activity.go @@ -5,11 +5,18 @@ package filedef import ( + "github.com/muktihari/fit/factory" "github.com/muktihari/fit/profile/mesgdef" "github.com/muktihari/fit/profile/untyped/mesgnum" "github.com/muktihari/fit/proto" ) +// Activity is a common file type that most wearable device or cycling computer uses to record activities. +// +// Please note since we group the same mesgdef types in a slice, we lose the arrival order of the messages. +// But for messages that have timestamp, we can reconstruct the messages by timestamp order. +// +// ref: https://developer.garmin.com/fit/file-types/activity/ type Activity struct { FileId *mesgdef.FileId Activity *mesgdef.Activity @@ -18,8 +25,8 @@ type Activity struct { Records []*mesgdef.Record // Optional Messages - DeviceInfo *mesgdef.DeviceInfo UserProfile *mesgdef.UserProfile + DeviceInfos []*mesgdef.DeviceInfo Events []*mesgdef.Event Lengths []*mesgdef.Length SegmentLap []*mesgdef.SegmentLap @@ -32,8 +39,13 @@ type Activity struct { // Developer Data Lookup DeveloperDataIds []*mesgdef.DeveloperDataId FieldDescriptions []*mesgdef.FieldDescription + + // Messages not related to Activity + UnrelatedMessages []proto.Message } +var _ File = &Activity{} + func NewActivity(mesgs ...proto.Message) *Activity { f := &Activity{} for i := range mesgs { @@ -56,7 +68,7 @@ func (f *Activity) Add(mesg proto.Message) { case mesgnum.Record: f.Records = append(f.Records, mesgdef.NewRecord(mesg)) case mesgnum.DeviceInfo: - f.DeviceInfo = mesgdef.NewDeviceInfo(mesg) + f.DeviceInfos = append(f.DeviceInfos, mesgdef.NewDeviceInfo(mesg)) case mesgnum.UserProfile: f.UserProfile = mesgdef.NewUserProfile(mesg) case mesgnum.Event: @@ -77,15 +89,22 @@ func (f *Activity) Add(mesg proto.Message) { f.DeveloperDataIds = append(f.DeveloperDataIds, mesgdef.NewDeveloperDataId(mesg)) case mesgnum.FieldDescription: f.FieldDescriptions = append(f.FieldDescriptions, mesgdef.NewFieldDescription(mesg)) + default: + f.UnrelatedMessages = append(f.UnrelatedMessages, mesg) } } -func (f *Activity) ToFit(factory Factory) proto.Fit { - var size = 4 // non slice fields +func (f *Activity) ToFit(fac Factory) proto.Fit { + if fac == nil { + fac = factory.StandardFactory() + } + + var size = 3 // non slice fields - size += len(f.Sessions) + len(f.Laps) + len(f.Records) + + size += len(f.Sessions) + len(f.Laps) + len(f.Records) + len(f.DeviceInfos) + len(f.Events) + len(f.Lengths) + len(f.SegmentLap) + len(f.ZoneTargets) + - len(f.Workouts) + len(f.WorkoutSteps) + len(f.HRs) + len(f.HRVs) + len(f.Workouts) + len(f.WorkoutSteps) + len(f.HRs) + len(f.HRVs) + + len(f.DeveloperDataIds) + len(f.FieldDescriptions) + len(f.UnrelatedMessages) fit := proto.Fit{ Messages: make([]proto.Message, 0, size), @@ -93,43 +112,43 @@ func (f *Activity) ToFit(factory Factory) proto.Fit { // Should be as ordered: FieldId, DeveloperDataId and FieldDescription if f.FileId != nil { - mesg := factory.CreateMesg(mesgnum.FileId) + mesg := fac.CreateMesg(mesgnum.FileId) f.FileId.PutMessage(&mesg) fit.Messages = append(fit.Messages, mesg) } - PutMessages(factory, &fit.Messages, mesgnum.DeveloperDataId, f.DeveloperDataIds) - PutMessages(factory, &fit.Messages, mesgnum.FieldDescription, f.FieldDescriptions) + PutMessages(fac, &fit.Messages, mesgnum.DeveloperDataId, f.DeveloperDataIds) + PutMessages(fac, &fit.Messages, mesgnum.FieldDescription, f.FieldDescriptions) - if f.Activity != nil { - mesg := factory.CreateMesg(mesgnum.Activity) - f.Activity.PutMessage(&mesg) - fit.Messages = append(fit.Messages, mesg) - } + PutMessages(fac, &fit.Messages, mesgnum.DeviceInfo, f.DeviceInfos) - if f.DeviceInfo != nil { - mesg := factory.CreateMesg(mesgnum.DeviceInfo) - f.DeviceInfo.PutMessage(&mesg) + if f.UserProfile != nil { + mesg := fac.CreateMesg(mesgnum.UserProfile) + f.UserProfile.PutMessage(&mesg) fit.Messages = append(fit.Messages, mesg) } - if f.UserProfile != nil { - mesg := factory.CreateMesg(mesgnum.UserProfile) - f.UserProfile.PutMessage(&mesg) + if f.Activity != nil { + mesg := fac.CreateMesg(mesgnum.Activity) + f.Activity.PutMessage(&mesg) fit.Messages = append(fit.Messages, mesg) } - PutMessages(factory, &fit.Messages, mesgnum.Session, f.Sessions) - PutMessages(factory, &fit.Messages, mesgnum.Lap, f.Laps) - PutMessages(factory, &fit.Messages, mesgnum.Record, f.Records) - PutMessages(factory, &fit.Messages, mesgnum.Event, f.Events) - PutMessages(factory, &fit.Messages, mesgnum.Length, f.Lengths) - PutMessages(factory, &fit.Messages, mesgnum.SegmentLap, f.SegmentLap) - PutMessages(factory, &fit.Messages, mesgnum.ZonesTarget, f.ZoneTargets) - PutMessages(factory, &fit.Messages, mesgnum.Workout, f.Workouts) - PutMessages(factory, &fit.Messages, mesgnum.WorkoutStep, f.WorkoutSteps) - PutMessages(factory, &fit.Messages, mesgnum.Hr, f.HRs) - PutMessages(factory, &fit.Messages, mesgnum.Hrv, f.HRVs) + PutMessages(fac, &fit.Messages, mesgnum.Session, f.Sessions) + PutMessages(fac, &fit.Messages, mesgnum.Lap, f.Laps) + PutMessages(fac, &fit.Messages, mesgnum.Record, f.Records) + PutMessages(fac, &fit.Messages, mesgnum.Event, f.Events) + PutMessages(fac, &fit.Messages, mesgnum.Length, f.Lengths) + PutMessages(fac, &fit.Messages, mesgnum.SegmentLap, f.SegmentLap) + PutMessages(fac, &fit.Messages, mesgnum.ZonesTarget, f.ZoneTargets) + PutMessages(fac, &fit.Messages, mesgnum.Workout, f.Workouts) + PutMessages(fac, &fit.Messages, mesgnum.WorkoutStep, f.WorkoutSteps) + PutMessages(fac, &fit.Messages, mesgnum.Hr, f.HRs) + PutMessages(fac, &fit.Messages, mesgnum.Hrv, f.HRVs) + + fit.Messages = append(fit.Messages, f.UnrelatedMessages...) + + SortMessagesByTimestamp(fit.Messages) return fit } diff --git a/profile/filedef/course.go b/profile/filedef/course.go index 97c3dd88..f83a361a 100644 --- a/profile/filedef/course.go +++ b/profile/filedef/course.go @@ -5,13 +5,21 @@ package filedef import ( + "github.com/muktihari/fit/factory" "github.com/muktihari/fit/profile/mesgdef" "github.com/muktihari/fit/profile/typedef" "github.com/muktihari/fit/profile/untyped/mesgnum" "github.com/muktihari/fit/proto" ) -type CourseFile struct { +// Course is a common file type used as points of courses to assist with on- and off-road navigation, +// to provide turn by turn directions, or with virtual training applications to simulate real-world activities. +// +// Please note since we group the same mesgdef types in a slice, we lose the arrival order of the messages. +// But for messages that have timestamp, we can reconstruct the messages by timestamp order. +// +// ref: https://developer.garmin.com/fit/file-types/course/ +type Course struct { FileId *mesgdef.FileId Course *mesgdef.Course Lap *mesgdef.Lap @@ -24,10 +32,15 @@ type CourseFile struct { // Developer Data Lookup DeveloperDataIds []*mesgdef.DeveloperDataId FieldDescriptions []*mesgdef.FieldDescription + + // Messages not related to Course + UnrelatedMessages []proto.Message } -func NewCourseFile(mesgs ...proto.Message) (f *CourseFile, ok bool) { - f = &CourseFile{} +var _ File = &Course{} + +func NewCourse(mesgs ...proto.Message) (f *Course, ok bool) { + f = &Course{} for i := range mesgs { f.Add(mesgs[i]) } @@ -39,7 +52,7 @@ func NewCourseFile(mesgs ...proto.Message) (f *CourseFile, ok bool) { return f, true } -func (f *CourseFile) Add(mesg proto.Message) { +func (f *Course) Add(mesg proto.Message) { switch mesg.Num { case mesgnum.FileId: f.FileId = mesgdef.NewFileId(mesg) @@ -57,5 +70,54 @@ func (f *CourseFile) Add(mesg proto.Message) { f.DeveloperDataIds = append(f.DeveloperDataIds, mesgdef.NewDeveloperDataId(mesg)) case mesgnum.FieldDescription: f.FieldDescriptions = append(f.FieldDescriptions, mesgdef.NewFieldDescription(mesg)) + default: + f.UnrelatedMessages = append(f.UnrelatedMessages, mesg) + } +} + +func (f *Course) ToFit(fac Factory) proto.Fit { + if fac == nil { + fac = factory.StandardFactory() + } + + size := 3 /* non slice fields */ + + size += len(f.Records) + len(f.Events) + len(f.CoursePoints) + + len(f.DeveloperDataIds) + len(f.FieldDescriptions) + len(f.UnrelatedMessages) + + fit := proto.Fit{ + Messages: make([]proto.Message, 0, size), + } + + // Should be as ordered: FieldId, DeveloperDataId and FieldDescription + if f.FileId != nil { + mesg := fac.CreateMesg(mesgnum.FileId) + f.FileId.PutMessage(&mesg) + fit.Messages = append(fit.Messages, mesg) + } + + PutMessages(fac, &fit.Messages, mesgnum.DeveloperDataId, f.DeveloperDataIds) + PutMessages(fac, &fit.Messages, mesgnum.FieldDescription, f.FieldDescriptions) + + if f.Course != nil { + mesg := fac.CreateMesg(mesgnum.Course) + f.Course.PutMessage(&mesg) + fit.Messages = append(fit.Messages, mesg) + } + + if f.Lap != nil { + mesg := fac.CreateMesg(mesgnum.Lap) + f.Lap.PutMessage(&mesg) + fit.Messages = append(fit.Messages, mesg) } + + PutMessages(fac, &fit.Messages, mesgnum.Record, f.Records) + PutMessages(fac, &fit.Messages, mesgnum.Event, f.Events) + PutMessages(fac, &fit.Messages, mesgnum.CoursePoint, f.CoursePoints) + + fit.Messages = append(fit.Messages, f.UnrelatedMessages...) + + SortMessagesByTimestamp(fit.Messages) + + return fit } diff --git a/profile/filedef/filedef.go b/profile/filedef/filedef.go index a835ec18..dcc99003 100644 --- a/profile/filedef/filedef.go +++ b/profile/filedef/filedef.go @@ -5,22 +5,27 @@ package filedef import ( + "github.com/muktihari/fit/kit/typeconv" "github.com/muktihari/fit/profile/typedef" "github.com/muktihari/fit/proto" + "golang.org/x/exp/slices" ) +// File is an interface for defining common type file, any defined common file type should implement +// the following methods to be able to work with Listener (and other building block in filedef package). type File interface { + // Add adds message into file structure. Add(mesg proto.Message) + // ToFit converts file back to proto.Fit structure. + ToFit(fac Factory) proto.Fit } +// Factory is factory interface used in filedef package to convert a common type file back to its proto.Fit representation. type Factory interface { CreateMesg(num typedef.MesgNum) proto.Message } -type PutMessage interface { - PutMessage(mesg *proto.Message) -} - +// PutMessages bulks put messages func PutMessages[S []E, E PutMessage](factory Factory, messages *[]proto.Message, mesgNum typedef.MesgNum, s S) { for i := range s { mesg := factory.CreateMesg(mesgNum) @@ -28,3 +33,37 @@ func PutMessages[S []E, E PutMessage](factory Factory, messages *[]proto.Message *messages = append(*messages, mesg) } } + +// PutMessage is a type constraint to retrieve all mesgdef structures which implement PutMessage method. +type PutMessage interface { + PutMessage(mesg *proto.Message) +} + +// SortMessagesByTimestamp sorts messages by timestamp only if the message has timestamp field. +// When a message has no timestamp field, its order will not be changed. +func SortMessagesByTimestamp(messages []proto.Message) { + slices.SortFunc(messages, func(m1, m2 proto.Message) int { + value1 := m1.FieldValueByNum(proto.FieldNumTimestamp) + if value1 == nil { + return 0 + } + + value2 := m2.FieldValueByNum(proto.FieldNumTimestamp) + if value2 == nil { + return 0 + } + + timestamp1 := typeconv.ToUint32[uint32](value1) + timestamp2 := typeconv.ToUint32[uint32](value2) + + if timestamp1 == timestamp2 { + return 0 + } + + if timestamp1 < timestamp2 { + return -1 + } + + return 1 + }) +} diff --git a/profile/filedef/listener.go b/profile/filedef/listener.go index c9f5ab8f..603ecac6 100644 --- a/profile/filedef/listener.go +++ b/profile/filedef/listener.go @@ -9,23 +9,31 @@ import ( ) // Listener is Message Listener. -type Listener[T File] struct { - file T - mesgc chan proto.Message - done chan struct{} +type Listener[F any, T FilePtr[F]] struct { + file F + options *options + mesgc chan proto.Message + done chan struct{} +} + +// FilePtr is a type constraint for pointer of File. +type FilePtr[T any] interface { + *T + File } // NewListener creates mesg listener for given file T. -func NewListener[T File](file T, opts ...Option) *Listener[T] { +func NewListener[F any, T FilePtr[F]](opts ...Option) *Listener[F, T] { options := defaultOptions() for _, opt := range opts { opt.apply(options) } - l := &Listener[T]{ - file: file, - mesgc: make(chan proto.Message, options.channelBuffer), - done: make(chan struct{}), + l := &Listener[F, T]{ + file: *new(F), + options: options, + mesgc: make(chan proto.Message, options.channelBuffer), + done: make(chan struct{}), } go l.loop() @@ -33,20 +41,38 @@ func NewListener[T File](file T, opts ...Option) *Listener[T] { return l } -func (l *Listener[T]) loop() { +func (l *Listener[F, T]) loop() { for mesg := range l.mesgc { - l.file.Add(mesg) + T(&l.file).Add(mesg) } close(l.done) } -func (l *Listener[T]) OnMesg(mesg proto.Message) { l.mesgc <- mesg } +func (l *Listener[F, T]) OnMesg(mesg proto.Message) { l.mesgc <- mesg } -// File returns the resulting file after the decode process is completed. -func (l *Listener[T]) File() T { +// Close closes channel and wait until all messages is consumed. +func (l *Listener[F, T]) Close() { close(l.mesgc) <-l.done - return l.file +} + +// File returns the resulting file after the a single decode process is completed. This will reset fields used by listener +// and the listener is ready to be used for next chained FIT file. +func (l *Listener[F, T]) File() T { + l.Close() + + file := l.file + l.reset() + + go l.loop() + + return T(&file) +} + +func (l *Listener[F, T]) reset() { + l.file = *new(F) + l.mesgc = make(chan proto.Message, l.options.channelBuffer) + l.done = make(chan struct{}) } type options struct { diff --git a/profile/filedef/workout.go b/profile/filedef/workout.go index 90e2ec60..5173284f 100644 --- a/profile/filedef/workout.go +++ b/profile/filedef/workout.go @@ -5,13 +5,17 @@ package filedef import ( + "github.com/muktihari/fit/factory" "github.com/muktihari/fit/profile/mesgdef" "github.com/muktihari/fit/profile/typedef" "github.com/muktihari/fit/profile/untyped/mesgnum" "github.com/muktihari/fit/proto" ) -type WorkoutFile struct { +// Workout is a file contains instructions for performing a structured activity. +// +// ref: https://developer.garmin.com/fit/file-types/workout/ +type Workout struct { FileId *mesgdef.FileId Workout *mesgdef.Workout WorkoutSteps []*mesgdef.WorkoutStep @@ -19,10 +23,15 @@ type WorkoutFile struct { // Developer Data Lookup DeveloperDataIds []*mesgdef.DeveloperDataId FieldDescriptions []*mesgdef.FieldDescription + + // Messages not related to Workout + UnrelatedMessages []proto.Message } -func NewWorkoutFile(mesgs ...proto.Message) (f *WorkoutFile, ok bool) { - f = &WorkoutFile{} +var _ File = &Workout{} + +func NewWorkout(mesgs ...proto.Message) (f *Workout, ok bool) { + f = &Workout{} for i := range mesgs { f.Add(mesgs[i]) } @@ -34,7 +43,7 @@ func NewWorkoutFile(mesgs ...proto.Message) (f *WorkoutFile, ok bool) { return f, true } -func (f *WorkoutFile) Add(mesg proto.Message) { +func (f *Workout) Add(mesg proto.Message) { switch mesg.Num { case mesgnum.FileId: f.FileId = mesgdef.NewFileId(mesg) @@ -46,5 +55,37 @@ func (f *WorkoutFile) Add(mesg proto.Message) { f.DeveloperDataIds = append(f.DeveloperDataIds, mesgdef.NewDeveloperDataId(mesg)) case mesgnum.FieldDescription: f.FieldDescriptions = append(f.FieldDescriptions, mesgdef.NewFieldDescription(mesg)) + default: + f.UnrelatedMessages = append(f.UnrelatedMessages, mesg) + } +} + +func (f *Workout) ToFit(fac Factory) proto.Fit { + if fac == nil { + fac = factory.StandardFactory() } + + size := 2 /* non slice fields */ + + size += len(f.WorkoutSteps) + len(f.DeveloperDataIds) + len(f.FieldDescriptions) + len(f.UnrelatedMessages) + + fit := proto.Fit{ + Messages: make([]proto.Message, 0, size), + } + + // Should be as ordered: FieldId, DeveloperDataId and FieldDescription + if f.FileId != nil { + mesg := fac.CreateMesg(mesgnum.FileId) + f.FileId.PutMessage(&mesg) + fit.Messages = append(fit.Messages, mesg) + } + + PutMessages(fac, &fit.Messages, mesgnum.DeveloperDataId, f.DeveloperDataIds) + PutMessages(fac, &fit.Messages, mesgnum.FieldDescription, f.FieldDescriptions) + + PutMessages(fac, &fit.Messages, mesgnum.WorkoutStep, f.WorkoutSteps) + + fit.Messages = append(fit.Messages, f.UnrelatedMessages...) + + return fit }