postrunner 0.0.11 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -55,7 +55,7 @@ module PostRunner
55
55
  start_time = session.start_time
56
56
  @fit_activity.data_sources.each do |source|
57
57
  t.cell(secsToHMS(source.timestamp - start_time))
58
- t.cell(distance(source.timestamp))
58
+ t.cell(@activity.distance(source.timestamp, @unit_system))
59
59
  t.cell(source.mode)
60
60
  t.cell(device_name(source.distance))
61
61
  t.cell(device_name(source.speed))
@@ -81,19 +81,6 @@ module PostRunner
81
81
  ''
82
82
  end
83
83
 
84
- def distance(timestamp)
85
- @fit_activity.records.each do |record|
86
- if record.timestamp >= timestamp
87
- unit = { :metric => 'km', :statute => 'mi'}[@unit_system]
88
- value = record.get_as('distance', unit)
89
- return '-' unless value
90
- return "#{'%.2f %s' % [value, unit]}"
91
- end
92
- end
93
-
94
- '-'
95
- end
96
-
97
84
  end
98
85
 
99
86
  end
@@ -55,19 +55,29 @@ module PostRunner
55
55
 
56
56
  def devices
57
57
  tables = []
58
- seen_indexes = []
58
+ unique_devices = []
59
+ # Search the device list from back to front and save the first occurance
60
+ # of each device index.
59
61
  @fit_activity.device_infos.reverse_each do |device|
60
- next if seen_indexes.include?(device.device_index)
62
+ unless unique_devices.find { |d| d.device_index == device.device_index }
63
+ unique_devices << device
64
+ end
65
+ end
61
66
 
67
+ unique_devices.sort { |d1, d2| d1.device_index <=>
68
+ d2.device_index }.each do |device|
62
69
  tables << (t = FlexiTable.new)
63
- t.set_html_attrs(:style, 'margin-bottom: 15px') if tables.length != 1
70
+ if tables.length != unique_devices.length
71
+ t.set_html_attrs(:style, 'margin-bottom: 15px')
72
+ end
64
73
  t.body
65
74
 
66
75
  t.cell('Index:', { :width => '40%' })
67
76
  t.cell(device.device_index.to_s, { :width => '60%' })
68
77
  t.new_row
69
78
 
70
- if (manufacturer = device.manufacturer)
79
+ if (manufacturer = device.manufacturer) &&
80
+ manufacturer != 'Undocumented value 0'
71
81
  t.cell('Manufacturer:', { :width => '40%' })
72
82
  t.cell(manufacturer.upcase, { :width => '60%' })
73
83
  t.new_row
@@ -75,7 +85,7 @@ module PostRunner
75
85
 
76
86
  if (product = %w( garmin dynastream dynastream_oem ).include?(
77
87
  device.manufacturer) ? device.garmin_product : device.product) &&
78
- product != 0xFFFF
88
+ product != 0xFFFF && product != 0
79
89
  # For unknown products the numerical ID will be returned.
80
90
  product = product.to_s unless product.is_a?(String)
81
91
  t.cell('Product:')
@@ -100,9 +110,9 @@ module PostRunner
100
110
  t.new_row
101
111
  end
102
112
 
103
- if device.software_version
113
+ if (version = device.software_version) && version != 0.0
104
114
  t.cell('Software Version:')
105
- t.cell(device.software_version)
115
+ t.cell(version)
106
116
  t.new_row
107
117
  end
108
118
 
@@ -123,11 +133,9 @@ module PostRunner
123
133
  t.cell(secsToDHMS(device.cum_operating_time))
124
134
  t.new_row
125
135
  end
126
-
127
- seen_indexes << device.device_index
128
136
  end
129
137
 
130
- tables.reverse
138
+ tables
131
139
  end
132
140
 
133
141
  end
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = DirUtils.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2014, 2015 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'fileutils'
14
+
15
+ module PostRunner
16
+
17
+ module DirUtils
18
+
19
+ def create_directory(dir, name)
20
+ return if Dir.exists?(dir)
21
+
22
+ Log.info "Creating #{name} directory #{dir}"
23
+ begin
24
+ FileUtils.mkdir_p(dir)
25
+ rescue StandardError
26
+ Log.fatal "Cannot create #{name} directory #{dir}: #{$!}"
27
+ end
28
+ end
29
+
30
+ end
31
+
32
+ end
33
+
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = EventList.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'fit4ruby'
14
+
15
+ require 'postrunner/FlexiTable'
16
+ require 'postrunner/ViewFrame'
17
+ require 'postrunner/DeviceList'
18
+
19
+ module PostRunner
20
+
21
+ # The EventList objects can generate a table that lists all the recorded
22
+ # FIT file events in chronological order.
23
+ class EventList
24
+
25
+ include Fit4Ruby::Converters
26
+
27
+ # Create a DataSources object.
28
+ # @param activity [Activity] The activity to analyze.
29
+ # @param unit_system [Symbol] The unit system to use (:metric or
30
+ # :imperial )
31
+ def initialize(activity, unit_system)
32
+ @activity = activity
33
+ @fit_activity = activity.fit_activity
34
+ @unit_system = unit_system
35
+ end
36
+
37
+ # Return the list as ASCII table
38
+ def to_s
39
+ list.to_s
40
+ end
41
+
42
+ # Add the list as HTML table to the specified doc.
43
+ # @param doc [HTMLBuilder] HTML document
44
+ def to_html(doc)
45
+ ViewFrame.new("Events", 600, list).to_html(doc)
46
+ end
47
+
48
+ private
49
+
50
+ def list
51
+ session = @fit_activity.sessions[0]
52
+
53
+ t = FlexiTable.new
54
+ t.enable_frame(false)
55
+ t.body
56
+ t.row([ 'Time', 'Distance', 'Description', 'Value' ])
57
+ t.set_column_attributes([
58
+ { :halign => :right },
59
+ { :halign => :right },
60
+ { :halign => :left },
61
+ { :halign => :right }
62
+ ])
63
+ start_time = session.start_time
64
+ @fit_activity.events.each do |event|
65
+ t.cell(secsToHMS(event.timestamp - start_time))
66
+ t.cell(@activity.distance(event.timestamp, @unit_system))
67
+ event_name_and_value(t, event)
68
+ t.new_row
69
+ end
70
+
71
+ t
72
+ end
73
+
74
+ def event_name_and_value(table, event)
75
+ case event.event
76
+ when 'timer'
77
+ name = "Timer (#{event.event_type.gsub(/_/, ' ')})"
78
+ value = event.timer_trigger
79
+ when 'course_point'
80
+ name = 'Course Point'
81
+ value = event.message_index
82
+ when 'battery'
83
+ name = 'Battery Level'
84
+ value = "#{event.battery_level} V"
85
+ when 'hr_high_alert'
86
+ name = 'HR high alert'
87
+ value = "#{event.hr_high_alert} bpm"
88
+ when 'hr_low_alert'
89
+ name = 'HR low alert'
90
+ value = "#{event.hr_low_alert} bpm"
91
+ when 'speed_high_alert'
92
+ name = 'Speed high alert'
93
+ value = event.speed_high_alert
94
+ when 'speed_low_alert'
95
+ name = 'Speed low alert'
96
+ value = event.speed_low_alert
97
+ when 'cad_high_alert'
98
+ name = 'Cadence high alert'
99
+ value = "#{event.cad_high_alert} spm"
100
+ when 'cad_low_alert'
101
+ name = 'Cadence low alert'
102
+ value = "#{event.cad_low_alert} spm"
103
+ when 'power_high_alert'
104
+ name = 'Power high alert'
105
+ value = event.power_high_alert
106
+ when 'power_low_alert'
107
+ name = 'Power low alert'
108
+ value = event.power_low_alert
109
+ when 'time_duration_alert'
110
+ name = 'Time duration alert'
111
+ value = event.time_duration_alert
112
+ when 'calorie_duration_alert'
113
+ name = 'Calorie duration alert'
114
+ value = event.calorie_duration_alert
115
+ when 'fitness_equipment'
116
+ name = 'Fitness equipment state'
117
+ value = event.fitness_equipment_state
118
+ when 'rider_position'
119
+ name 'Rider position changed'
120
+ value = event.rider_position
121
+ when 'comm_timeout'
122
+ name 'Communication timeout'
123
+ value = event.comm_timeout
124
+ when 'recovery_hr'
125
+ name = 'Recovery heart rate'
126
+ value = "#{event.recovery_hr} bpm"
127
+ when 'recovery_time'
128
+ name = 'Recovery time'
129
+ value = "#{secsToDHMS(event.recovery_time * 60)}"
130
+ when 'recovery_info'
131
+ name = 'Recovery info'
132
+ mins = event.recovery_info
133
+ value = "#{secsToDHMS(mins * 60)} (#{mins < 24 * 60 ? 'Good' : 'Poor'})"
134
+ when 'vo2max'
135
+ name = 'VO2Max'
136
+ value = event.vo2max
137
+ else
138
+ name = event.event
139
+ value = event.data
140
+ end
141
+
142
+ table.cell(name)
143
+ table.cell(value)
144
+ end
145
+
146
+ end
147
+
148
+ end
149
+
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env ruby -w
2
+ # encoding: UTF-8
3
+ #
4
+ # = FFS_Activity.rb -- PostRunner - Manage the data from your Garmin sport devices.
5
+ #
6
+ # Copyright (c) 2015, 2016 by Chris Schlaeger <cs@taskjuggler.org>
7
+ #
8
+ # This program is free software; you can redistribute it and/or modify
9
+ # it under the terms of version 2 of the GNU General Public License as
10
+ # published by the Free Software Foundation.
11
+ #
12
+
13
+ require 'fit4ruby'
14
+ require 'perobs'
15
+
16
+ require 'postrunner/ActivitySummary'
17
+ require 'postrunner/DataSources'
18
+ require 'postrunner/EventList'
19
+ require 'postrunner/ActivityView'
20
+ require 'postrunner/Schema'
21
+ require 'postrunner/QueryResult'
22
+ require 'postrunner/DirUtils'
23
+
24
+ module PostRunner
25
+
26
+ # The FFS_Activity objects can store a reference to the FIT file data and
27
+ # caches some frequently used values. In some cases the cached values can be
28
+ # used to overwrite the data from the FIT file.
29
+ class FFS_Activity < PEROBS::Object
30
+
31
+ include DirUtils
32
+
33
+ @@Schemata = {
34
+ 'long_date' => Schema.new('long_date', 'Date',
35
+ { :func => 'timestamp',
36
+ :column_alignment => :left,
37
+ :format => 'date_with_weekday' }),
38
+ 'sub_type' => Schema.new('sub_type', 'Subtype',
39
+ { :func => 'activity_sub_type',
40
+ :column_alignment => :left }),
41
+ 'type' => Schema.new('type', 'Type',
42
+ { :func => 'activity_type',
43
+ :column_alignment => :left })
44
+ }
45
+
46
+ ActivityTypes = {
47
+ 'generic' => 'Generic',
48
+ 'running' => 'Running',
49
+ 'cycling' => 'Cycling',
50
+ 'transition' => 'Transition',
51
+ 'fitness_equipment' => 'Fitness Equipment',
52
+ 'swimming' => 'Swimming',
53
+ 'basketball' => 'Basketball',
54
+ 'soccer' => 'Soccer',
55
+ 'tennis' => 'Tennis',
56
+ 'american_football' => 'American Football',
57
+ 'walking' => 'Walking',
58
+ 'cross_country_skiing' => 'Cross Country Skiing',
59
+ 'alpine_skiing' => 'Alpine Skiing',
60
+ 'snowboarding' => 'Snowboarding',
61
+ 'rowing' => 'Rowing',
62
+ 'mountaineering' => 'Mountaneering',
63
+ 'hiking' => 'Hiking',
64
+ 'multisport' => 'Multisport',
65
+ 'paddling' => 'Paddling',
66
+ 'all' => 'All'
67
+ }
68
+ ActivitySubTypes = {
69
+ 'generic' => 'Generic',
70
+ 'treadmill' => 'Treadmill',
71
+ 'street' => 'Street',
72
+ 'trail' => 'Trail',
73
+ 'track' => 'Track',
74
+ 'spin' => 'Spin',
75
+ 'indoor_cycling' => 'Indoor Cycling',
76
+ 'road' => 'Road',
77
+ 'mountain' => 'Mountain',
78
+ 'downhill' => 'Downhill',
79
+ 'recumbent' => 'Recumbent',
80
+ 'cyclocross' => 'Cyclocross',
81
+ 'hand_cycling' => 'Hand Cycling',
82
+ 'track_cycling' => 'Track Cycling',
83
+ 'indoor_rowing' => 'Indoor Rowing',
84
+ 'elliptical' => 'Elliptical',
85
+ 'stair_climbing' => 'Stair Climbing',
86
+ 'lap_swimming' => 'Lap Swimming',
87
+ 'open_water' => 'Open Water',
88
+ 'flexibility_training' => 'Flexibility Training',
89
+ 'strength_training' => 'Strength Training',
90
+ 'warm_up' => 'Warm up',
91
+ 'match' => 'Match',
92
+ 'exercise' => 'Excersize',
93
+ 'challenge' => 'Challenge',
94
+ 'indoor_skiing' => 'Indoor Skiing',
95
+ 'cardio_training' => 'Cardio Training',
96
+ 'all' => 'All'
97
+ }
98
+
99
+ po_attr :device, :fit_file_name, :norecord, :name, :sport, :sub_sport,
100
+ :timestamp, :total_distance, :total_timer_time, :avg_speed
101
+ attr_reader :fit_activity
102
+
103
+ # Create a new FFS_Activity object.
104
+ # @param p [PEROBS::Handle] PEROBS handle
105
+ # @param fit_file_name [String] The fully qualified file name of the FIT
106
+ # file to add
107
+ # @param fit_entity [Fit4Ruby::FitEntity] The content of the loaded FIT
108
+ # file
109
+ def initialize(p, device, fit_file_name, fit_entity)
110
+ super(p)
111
+
112
+ self.device = device
113
+ self.fit_file_name = fit_file_name ? File.basename(fit_file_name) : nil
114
+ self.name = fit_file_name ? File.basename(fit_file_name) : nil
115
+ self.norecord = false
116
+ if (@fit_activity = fit_entity)
117
+ self.timestamp = fit_entity.timestamp
118
+ self.total_timer_time = fit_entity.total_timer_time
119
+ self.sport = fit_entity.sport
120
+ self.sub_sport = fit_entity.sub_sport
121
+ self.total_distance = fit_entity.total_distance
122
+ self.avg_speed = fit_entity.avg_speed
123
+ end
124
+ end
125
+
126
+ # Store a copy of the given FIT file in the corresponding directory.
127
+ # @param fit_file_name [String] Fully qualified name of the FIT file.
128
+ def store_fit_file(fit_file_name)
129
+ # Get the right target directory for this particular FIT file.
130
+ dir = @store['file_store'].fit_file_dir(File.basename(fit_file_name),
131
+ @device.long_uid, 'activity')
132
+ # Create the necessary directories if they don't exist yet.
133
+ create_directory(dir, 'Device activity diretory')
134
+
135
+ # Copy the file into the target directory.
136
+ begin
137
+ FileUtils.cp(fit_file_name, dir)
138
+ rescue StandardError
139
+ Log.fatal "Cannot copy #{fit_file_name} into #{dir}: #{$!}"
140
+ end
141
+ end
142
+
143
+ # FFS_Activity objects are sorted by their timestamp values and then by
144
+ # their device long_uids.
145
+ def <=>(a)
146
+ @timestamp == a.timestamp ? a.device.long_uid <=> self.device.long_uid :
147
+ a.timestamp <=> @timestamp
148
+ end
149
+
150
+ def check
151
+ generate_html_report
152
+ Log.info "FIT file #{@fit_file_name} is OK"
153
+ end
154
+
155
+ def dump(filter)
156
+ load_fit_file(filter)
157
+ end
158
+
159
+ def dump(filter)
160
+ load_fit_file(filter)
161
+ end
162
+
163
+ def query(key)
164
+ unless @@Schemata.include?(key)
165
+ raise ArgumentError, "Unknown key '#{key}' requested in query"
166
+ end
167
+
168
+ schema = @@Schemata[key]
169
+
170
+ if schema.func
171
+ value = send(schema.func)
172
+ else
173
+ unless instance_variable_defined?(key)
174
+ raise ArgumentError, "Don't know how to query '#{key}'"
175
+ end
176
+ value = instance_variable_get(key)
177
+ end
178
+
179
+ QueryResult.new(value, schema)
180
+ end
181
+
182
+ def events
183
+ load_fit_file
184
+ puts EventList.new(self, @store['config']['unit_system']).to_s
185
+ end
186
+
187
+ def show
188
+ html_file = html_file_name
189
+
190
+ generate_html_report #unless File.exists?(html_file)
191
+
192
+ @store['file_store'].show_in_browser(html_file)
193
+ end
194
+
195
+ def sources
196
+ load_fit_file
197
+ puts DataSources.new(self, @store['config']['unit_system']).to_s
198
+ end
199
+
200
+ def summary
201
+ load_fit_file
202
+ puts ActivitySummary.new(self, @store['config']['unit_system'],
203
+ { :name => @name,
204
+ :type => activity_type,
205
+ :sub_type => activity_sub_type }).to_s
206
+ end
207
+
208
+ def set(attribute, value)
209
+ case attribute
210
+ when 'name'
211
+ self.name = value
212
+ when 'type'
213
+ load_fit_file
214
+ unless ActivityTypes.values.include?(value)
215
+ Log.fatal "Unknown activity type '#{value}'. Must be one of " +
216
+ ActivityTypes.values.join(', ')
217
+ end
218
+ self.sport = ActivityTypes.invert[value]
219
+ when 'subtype'
220
+ unless ActivitySubTypes.values.include?(value)
221
+ Log.fatal "Unknown activity subtype '#{value}'. Must be one of " +
222
+ ActivitySubTypes.values.join(', ')
223
+ end
224
+ self.sub_sport = ActivitySubTypes.invert[value]
225
+ when 'norecord'
226
+ unless %w( true false).include?(value)
227
+ Log.fatal "norecord must either be 'true' or 'false'"
228
+ end
229
+ self.norecord = value == 'true'
230
+ else
231
+ Log.fatal "Unknown activity attribute '#{attribute}'. Must be one of " +
232
+ 'name, type or subtype'
233
+ end
234
+ generate_html_report
235
+ end
236
+
237
+ # Return true if this activity generated any personal records.
238
+ def has_records?
239
+ !@store['records'].activity_records(self).empty?
240
+ end
241
+
242
+ def html_file_name(full_path = true)
243
+ fn = "#{@device.short_uid}_#{@fit_file_name[0..-5]}.html"
244
+ full_path ? File.join(@store['config']['html_dir'], fn) : fn
245
+ end
246
+
247
+ def generate_html_report
248
+ load_fit_file
249
+ ActivityView.new(self, @store['config']['unit_system'])
250
+ end
251
+
252
+ def activity_type
253
+ ActivityTypes[@sport] || 'Undefined'
254
+ end
255
+
256
+ def activity_sub_type
257
+ ActivitySubTypes[@sub_sport] || 'Undefined'
258
+ end
259
+
260
+ def distance(timestamp, unit_system)
261
+ load_fit_file
262
+
263
+ @fit_activity.records.each do |record|
264
+ if record.timestamp >= timestamp
265
+ unit = { :metric => 'km', :statute => 'mi'}[unit_system]
266
+ value = record.get_as('distance', unit)
267
+ return '-' unless value
268
+ return "#{'%.2f %s' % [value, unit]}"
269
+ end
270
+ end
271
+
272
+ '-'
273
+ end
274
+
275
+ def load_fit_file(filter = nil)
276
+ return if @fit_activity
277
+
278
+ dir = @store['file_store'].fit_file_dir(@fit_file_name,
279
+ @device.long_uid, 'activity')
280
+ fit_file = File.join(dir, @fit_file_name)
281
+ begin
282
+ @fit_activity = Fit4Ruby.read(fit_file, filter)
283
+ rescue Fit4Ruby::Error
284
+ Log.fatal "#{@fit_file_name} corrupted: #{$!}"
285
+ end
286
+
287
+ unless @fit_activity
288
+ Log.fatal "#{fit_file} does not contain any activity records"
289
+ end
290
+ end
291
+
292
+ private
293
+
294
+ end
295
+
296
+ end
297
+