postrunner 0.0.11 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+